Compare commits

...

60 Commits

Author SHA1 Message Date
portainer-bot[bot] 6486934a33 feat(bump): LTS patch version bump from 2.39.2 to 2.39.3 (#2796)
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
2026-06-03 22:06:06 +00:00
andres-portainer 44af81a465 feat(useractivity): fix a goroutine leak BE-12969 (#2784) 2026-06-02 18:26:00 -03:00
Oscar Zhou f06b9eadac fix(stacks): remove partial clone directory on failed git clone (#2752) 2026-06-03 09:09:35 +12:00
Devon Steenberg 2750028eba fix(registries): make gitlab proxy endpoint admin only [BE-13018] (#2777) 2026-06-03 08:48:39 +12:00
andres-portainer 6d2e35f907 fix(security): fix a short-circuit condition that can lead to improper access control BE-13020 (#2757) 2026-05-29 20:48:09 -03:00
Devon Steenberg 21498532e0 fix(swarm): service creation networks [BE-12996] (#2737) 2026-05-26 13:49:37 +12:00
andres-portainer bffbbfc32e fix(crypto): upgrade golang.org/x/crypto to v0.52.0 to fix CVE-2026-39830 CVE-2026-39831 CVE-2026-39832 CVE-2026-39833 CVE-2026-39834 CVE-2026-42508 CVE-2026-46595 BE-12992 (#2731) 2026-05-25 19:05:54 -03:00
Devon Steenberg 4581a7760f fix(libstack): use compose service to pull images [BE-12951] (#2659) 2026-05-18 09:25:43 +12:00
andres-portainer 71323f43fa fix(net): upgrade golang.org/x/net to v0.54.0 to fix CVE-2026-27141 CVE-2026-33814 BE-12965 (#2632) 2026-05-14 21:47:48 -03:00
andres-portainer 3a9365ea85 fix(in-toto-golang): upgrade github.com/in-toto/in-toto-golang to v0.11.0 to fix GHSA-pmwq-pjrm-6p5r BE-12968 (#2639) 2026-05-14 19:01:50 -03:00
andres-portainer 11b18b0878 fix(go-git): upgrade github.com/go-git/go-git/v5 to v5.19.0 to fix CVE-2026-34165 GHSA-3xc5-wrhm-f963 CVE-2026-33762 BE-12966 (#2635) 2026-05-13 15:31:28 -03:00
andres-portainer 1fbb98d387 fix(chisel): add another mechanism to ensure snapshot collection BE-12896 (#2629) 2026-05-13 10:50:53 -03:00
andres-portainer 9083c09fd1 fix(chisel): upgrade Chisel to v1.11.6 to avoid a panic because of a negative waitgroup counter BE-12743 (#2630) 2026-05-12 17:34:12 -03:00
Josiah Clumont c954943865 chore(version): Bump 2.39.1 to 2.39.2 (#2585) 2026-05-07 14:44:51 +12:00
Oscar Zhou d54ccd5502 fix(ecr): prevent deadlock on ECR token refresh during stack deployment [BE-12842] (#2544) 2026-05-06 15:55:57 +12:00
Devon Steenberg 7e526c4df7 fix(libstack): pull images sequentially and respect COMPOSE_PARALLEL_LIMIT [BE-12930] (#2555) 2026-05-06 15:16:14 +12:00
Devon Steenberg bf56a6c913 fix(kubectl-shell): kubectl-shell-image flag [BE-12929] (#2545) 2026-05-06 08:13:12 +12:00
andres-portainer 6b1b6ff998 fix(docker): add missing restrictions for Swarm BE-12772 (#2557) 2026-05-05 11:35:37 -03:00
andres-portainer 9183be7a8c fix(docker): add more bind mount restriction checks BE-12771 (#2551) 2026-05-05 09:25:21 -03:00
Steven Kang 7f83d15812 fix(security): bump CVE-affected dependencies and Alpine base images - release 2.39.2 [R8S-1002] (#2563) 2026-05-05 16:20:17 +12:00
andres-portainer f926b61978 fix(datastore): change EnforceEdgeID default to true BE-12925 (#2548) 2026-05-04 20:52:37 -03:00
andres-portainer cc5f790f98 fix(docker): enforce bind mount restrictions for Mounts field BE-12770 (#2528) 2026-05-04 13:06:02 -03:00
andres-portainer 40b210a708 fix(environments): fix the TLS certificate uploading BE-12719 (#2101) (#2534) 2026-05-04 12:59:15 -03:00
andres-portainer 8be327f087 fix(git): forbid the usage of symlinks BE-12768 (#2532) 2026-05-04 12:57:29 -03:00
LP B f498d76c4f fix(app/container): handle no healthcheck logs output (#2388) 2026-04-21 18:40:03 -03:00
andres-portainer a1fa77cbe4 fix(kubernetes): enforce admin permissions in /system BE-12862 (#2397) 2026-04-21 17:04:01 -03:00
RHCowan e11c2e9611 chore(ci): update CI changes from develop (#2379)
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Devon Steenberg <devon.steenberg@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2026-04-21 14:36:10 +12:00
andres-portainer 6e03a801e6 fix(endpoints): enforce admin permissions when updating endpoint relations BE-12861 (#2395) 2026-04-20 14:29:22 -03:00
LP B 6024a97892 fix(api): deny plugin related changes to regular users (#2297) 2026-04-20 12:29:03 -03:00
andres-portainer 1549b36103 fix(websocket): remove the JWT token query string parameter BE-12833 (#2334) 2026-04-16 14:10:37 -03:00
Chaim Lev-Ari 7bb3e0f7a6 chore(deps): upgrade ts to v6 [BE-12820] (#2272) 2026-04-15 03:55:42 +03:00
Chaim Lev-Ari 49cc901dc3 fix(gitops): save git credentials [BE-12773] (#2240) 2026-04-09 09:25:20 +03:00
andres-portainer 31dd62fbcc fix(containers): avoid using the request context BE-12870 (#2217) 2026-04-08 12:39:41 -03:00
Chaim Lev-Ari a7d2d134d0 fix(stacks): stack.env can be null [BE-12736] (#2241) 2026-04-06 16:56:49 +03:00
Phil Calder 14f25f1e88 fix(kubernetes): filter CronJob executions by namespace [DEV-19] (#2154)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:29:39 +13:00
Oscar Zhou 69b8b8373f fix(edge/agent): deleting k8s edge agent disconnect environment [BE-12723] (#2125) 2026-03-24 11:36:25 +13:00
Josiah Clumont 0b075e6e10 fix(grpc): upgrade to v1.79.3 to mitigate CVE-2026-33186 [C9S-55] (#2089) 2026-03-20 07:17:49 +13:00
Chaim Lev-Ari b468160606 fix(stacks): disabled edit button while submit [BE-12681] (#2093) 2026-03-19 16:09:16 +02:00
Josiah Clumont a42e96b650 chore: bump 2.39.0 to 2.39.1 (#2085) 2026-03-19 08:21:14 +13:00
Chaim Lev-Ari 5a19f66a37 fix(stacks): validate stacks with env vars [BE-12689] (#2051) 2026-03-17 20:05:16 -03:00
andres-portainer b271026188 fix(otel): upgrade to v1.42.0 BE-12724 (#2071) 2026-03-17 13:02:50 -03:00
LP B d168e3c912 fix(api/uac): panic on external stacks UAC eval (#2074) 2026-03-17 16:00:39 +01:00
andres-portainer 0b6ebd70e0 fix(go): upgrade Go to v1.25.8 to mitigate CVEs BE-12721 (#2066) 2026-03-17 09:36:48 -03:00
andres-portainer 127e03552a fix(docker): upgrade Docker binary to v29.3.0 to mitigate CVE-2025-68121 BE-12720 (#2063) 2026-03-16 17:52:30 -03:00
Chaim Lev-Ari f2bdfc6eff fix(kube/app): enable edit button for regular apps [BE-12690] (#2040) 2026-03-15 11:48:38 +02:00
Cara Ryan 5db67faa00 chore(kubernetes): Upgrade k8s deps to 0.35 [C9S-32] (#2042) 2026-03-13 15:44:38 +13:00
andres-portainer f9dcfcb435 fix(GO-2026-4550): upgrade circl to v1.6.3 BE-12694 (#2012) 2026-03-06 14:29:28 -03:00
LP B 1d1bb526d0 fix(app/container): query env registries instead of system registries (#1997) 2026-03-06 14:18:09 -03:00
LP B c8fe8ba4fd fix(app): paginate nested tables (#1999) 2026-03-06 14:17:34 -03:00
andres-portainer d3692a5a5f fix(GO-2026-4473): upgrade go-git to v5.17.0 BE-12693 (#2009) 2026-03-06 11:24:08 -03:00
LP B 3407811c28 fix(app/stack): virtual grouping in EnvSelector for non admins (#2002) 2026-03-06 15:00:28 +01:00
andres-portainer b71db0d1f1 fix(GO-2026-4394): upgrade opentelemetry to v1.41.0 BE-12692 (#2004) 2026-03-06 08:31:22 -03:00
LP B 5e5e85ff3a fix(api/custom_template): validate UAC when retrieving custom template file (#1981) 2026-03-04 13:22:09 +01:00
RHCowan 65d82e12ee fix(policy) avoid URL length limit when adding environments to large groups [R8S-893] (#1970) (#1972) 2026-02-27 12:00:54 +13:00
Steven Kang d9e730e0a5 fix(kubernetes): local exec to fall back to SPDY - release 2.39 [R8S-873] (#1947) 2026-02-25 15:46:16 +13:00
Ali 21eb20b35e fix(environment-groups): allow bulk selecting environments on create and edit [r8s-872] (#1956)
Merging because the failed system tests are related to helm and not environment groups
2026-02-24 17:53:06 +13:00
Steven Kang f85a7ea24c fix(environment): collapsing More options breaking the style for podman - release 2.39 [R8S-874] (#1943) 2026-02-24 10:11:50 +13:00
Oscar Zhou 6aacb61c87 fix(stack): avoid removing running service if stack deployment fails [BE-12542] (#1941) 2026-02-24 08:41:53 +13:00
andres-portainer bb2c75ba93 fix(policies): fixes for async edge R8S-661 (#1934) 2026-02-20 17:45:38 -03:00
Steven Kang 16536c8a71 feat(environment): reorder options - release 2.39 [R8S-524] (#1924) 2026-02-20 14:58:05 +13:00
132 changed files with 4586 additions and 1486 deletions
+3
View File
@@ -114,6 +114,9 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
'@typescript-eslint/switch-exhaustiveness-check': 'error'
'consistent-return': 'off'
'default-case': off
'jsx-a11y/label-has-associated-control':
- error
- assert: either
+9 -7
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
import postcss from 'postcss';
@@ -85,12 +86,7 @@ const config: StorybookConfig = {
...config,
resolve: {
...config.resolve,
plugins: [
...(config.resolve?.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve?.extensions,
}),
],
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
},
module: {
...config.module,
@@ -101,11 +97,17 @@ const config: StorybookConfig = {
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
},
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {},
};
export default config;
+8 -4
View File
@@ -11,7 +11,7 @@ see also:
## Package Manager
- **PNPM** 10+ (for frontend)
- **Go** 1.25.7 (for backend)
- **Go** 1.25.8 (for backend)
## Build Commands
@@ -27,9 +27,13 @@ make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
pnpm run dev # Webpack dev server
pnpm run build # Build frontend with webpack
pnpm run test # Run frontend tests
# Frontend
pnpm dev # Webpack dev server
pnpm build # Build frontend with webpack
pnpm typecheck # Run typecheck for frontend (with tsc)
pnpm lint # lint frontend (with eslint)
pnpm test # test frontend (with vitest)
pnpm format # format frontend (with prettier)
# Testing
make test # All tests (backend + frontend)
+5 -2
View File
@@ -4,7 +4,8 @@ WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
GOTESTSUM_VERSION?=v1.13.0
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
test-client: ## Run client tests
pnpm run test $(ARGS) --coverage
TEST_PACKAGES?=./...
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
##@ Dev
.PHONY: dev dev-client dev-server
+4 -4
View File
@@ -8,8 +8,8 @@ import (
"time"
)
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
if err != nil {
return
}
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
return
}
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
if err != nil {
return
}
+48 -11
View File
@@ -252,8 +252,9 @@ func (service *Service) startTunnelVerificationLoop() {
}
}
// checkTunnels finds the first tunnel that has not had any activity recently
// and attempts to take a snapshot, then closes it and returns
// checkTunnels finds tunnels that need snapshots and processes them one at a time.
// For active tunnels missing an initial snapshot, it takes one without closing the tunnel.
// For tunnels idle past activeTimeout, it snapshots and closes them.
func (service *Service) checkTunnels() {
service.mu.RLock()
@@ -264,12 +265,32 @@ func (service *Service) checkTunnels() {
Float64("last_activity_seconds", elapsed.Seconds()).
Msg("environment tunnel monitoring")
tunnelPort := tunnel.Port
if !tunnel.HasSnapshot && elapsed < activeTimeout {
service.mu.RUnlock()
if endpointHasSnapshot(service.dataStore, endpointID) {
service.markSnapshotTaken(endpointID)
return
}
log.Debug().
Int("endpoint_id", int(endpointID)).
Msg("taking initial snapshot for active Edge environment")
if service.snapshotAndLog(endpointID, tunnelPort) {
service.markSnapshotTaken(endpointID)
}
return
}
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
continue
}
tunnelPort := tunnel.Port
service.mu.RUnlock()
log.Debug().
@@ -278,13 +299,7 @@ func (service *Service) checkTunnels() {
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("last activity timeout exceeded")
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.snapshotAndLog(endpointID, tunnelPort)
service.close(endpointID)
return
@@ -293,6 +308,28 @@ func (service *Service) checkTunnels() {
service.mu.RUnlock()
}
func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPort int) bool {
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
return false
}
return true
}
func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) {
service.mu.Lock()
defer service.mu.Unlock()
if tun, ok := service.activeTunnels[endpointID]; ok {
tun.HasSnapshot = true
}
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
+164 -3
View File
@@ -2,6 +2,7 @@ package chisel
import (
"context"
"errors"
"net"
"net/http"
"testing"
@@ -18,13 +19,36 @@ func init() {
fips.InitFIPS(false)
}
func TestPingAgentPanic(t *testing.T) {
endpoint := &portainer.Endpoint{
ID: 1,
type mockSnapshotService struct {
snapshotFn func(endpoint *portainer.Endpoint) error
}
func (m *mockSnapshotService) Start() {}
func (m *mockSnapshotService) SetSnapshotInterval(_ string) error { return nil }
func (m *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
if m.snapshotFn != nil {
return m.snapshotFn(endpoint)
}
return nil
}
func (m *mockSnapshotService) FillSnapshotData(_ *portainer.Endpoint, _ bool) error { return nil }
func newEdgeEndpoint(id portainer.EndpointID) *portainer.Endpoint {
return &portainer.Endpoint{
ID: id,
EdgeID: "test-edge-id",
Type: portainer.EdgeAgentOnDockerEnvironment,
UserTrusted: true,
}
}
func TestPingAgentPanic(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(1)
_, store := datastore.MustNewTestStore(t, true, true)
@@ -57,3 +81,140 @@ func TestPingAgentPanic(t *testing.T) {
require.NoError(t, srv.Shutdown(context.Background()))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
}
func TestOpenDefaultsHasSnapshotToFalse(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(1)
_, store := datastore.MustNewTestStore(t, false, true)
s := NewService(store, nil, nil)
err := s.Open(endpoint)
require.NoError(t, err)
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot)
}
func TestCheckTunnelsSetsHasSnapshotWhenSnapshotExists(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(2)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snap := &portainer.Snapshot{
EndpointID: endpoint.ID,
Docker: &portainer.DockerSnapshot{},
}
err = store.Snapshot().Create(snap)
require.NoError(t, err)
s := NewService(store, nil, nil)
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50003,
LastActivity: time.Now(),
}
s.checkTunnels()
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open")
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
}
func TestCheckTunnelsSnapshotsActiveEnvironmentAndKeepsTunnelAlive(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(3)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snapshotCalled := false
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
snapshotCalled = true
return nil
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50000,
LastActivity: time.Now(),
}
s.checkTunnels()
require.True(t, snapshotCalled)
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after snapshot")
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
}
func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(4)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
return errors.New("snapshot failed")
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50001,
LastActivity: time.Now(),
}
s.checkTunnels()
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after failed snapshot")
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
}
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
t.Parallel()
endpoint := newEdgeEndpoint(5)
_, store := datastore.MustNewTestStore(t, false, true)
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
snapshotCalled := false
svc := &mockSnapshotService{
snapshotFn: func(_ *portainer.Endpoint) error {
snapshotCalled = true
return nil
},
}
s := NewService(store, nil, nil)
s.snapshotService = svc
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: 50002,
LastActivity: time.Now().Add(-(activeTimeout + time.Second)),
}
s.checkTunnels()
require.True(t, snapshotCalled)
require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout")
}
+10
View File
@@ -9,6 +9,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -237,3 +238,12 @@ func encryptCredentials(username, password, key string) (string, error) {
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
}
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
s, err := dataStore.Snapshot().Read(endpointID)
if err != nil {
return false
}
return s.Docker != nil || s.Kubernetes != nil
}
+9 -2
View File
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
flags.KubectlShellImage = kingpin.Flag(
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
).Envar(portainer.KubectlShellImageEnvVar).
Default(portainer.DefaultKubectlShellImage).
IsSetByUser(&hasKubectlShellImageFlag)
flags.KubectlShellImage = kubectlShellImageFlag.String()
kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
+54
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
require.True(t, *opts.EnableEdgeComputeFeatures)
}
func TestParseKubectlShellImageFlag(t *testing.T) {
tests := []struct {
name string
args []string
envVars map[string]string
expectedKubectlShellImageSet bool
expectedKubectlShellFlag string
}{
{
name: "no flag, no env var",
expectedKubectlShellImageSet: false,
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
},
{
name: "explicit flag",
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v2",
},
{
name: "env var",
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v3",
},
{
name: "both env var and flag set",
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v2",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.args == nil {
tc.args = []string{"portainer"}
}
setOsArgs(t, tc.args)
for k, v := range tc.envVars {
t.Setenv(k, v)
}
flags, err := Service{}.ParseFlags("test-version")
require.NoError(t, err)
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
})
}
}
func TestParseTLSFlags(t *testing.T) {
testCases := []struct {
name string
+4
View File
@@ -248,6 +248,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if flags.KubectlShellImageSet {
settings.KubectlShellImage = *flags.KubectlShellImage
}
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
}
+63 -1
View File
@@ -5,6 +5,9 @@ import (
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +15,7 @@ import (
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0600)
err := os.WriteFile(secretPath, []byte(password), 0o600)
require.NoError(t, err)
return secretPath
}
@@ -38,6 +41,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
require.Len(t, encryptionKey, 32)
}
func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
const existingImage = "existing-image:v1"
const newImage = "new-image:v2"
emptyString := ""
falseBool := false
var emptyLabels []portainer.Pair
tests := []struct {
name string
imageSet bool
flagImage string
expectedKubectlShellImage string
}{
{
name: "flag not set — DB image unchanged",
imageSet: false,
flagImage: portainer.DefaultKubectlShellImage,
expectedKubectlShellImage: existingImage,
},
{
name: "flag set — DB image updated",
imageSet: true,
flagImage: newImage,
expectedKubectlShellImage: newImage,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := testhelpers.NewDatastore(
testhelpers.WithSettingsService(&portainer.Settings{
KubectlShellImage: existingImage,
}),
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
)
flags := &portainer.CLIFlags{
SnapshotInterval: &emptyString,
Logo: &emptyString,
EnableEdgeComputeFeatures: &falseBool,
Templates: &emptyString,
Labels: &emptyLabels,
HTTPDisabled: &falseBool,
HTTPEnabled: &falseBool,
}
flags.KubectlShellImage = &tc.flagImage
flags.KubectlShellImageSet = tc.imageSet
err := updateSettingsFromFlags(store, flags)
require.NoError(t, err)
settings, err := store.Settings().Settings()
require.NoError(t, err)
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
})
}
}
func TestDBSecretPath(t *testing.T) {
tests := []struct {
keyFilenameFlag string
+1
View File
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
@@ -613,7 +613,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.3",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -942,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.39.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+11 -15
View File
@@ -66,7 +66,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
@@ -97,7 +97,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
Remove: options.Remove,
Args: options.Args,
@@ -146,7 +146,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
@@ -229,7 +229,12 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
// intentionally performs no DB writes to avoid write-lock contention when called inside
// an active BoltDB write transaction.
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
@@ -242,7 +247,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil {
continue
}
@@ -254,16 +259,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
+72
View File
@@ -6,6 +6,7 @@ import (
"path"
"path/filepath"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
@@ -94,3 +95,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
}
func Test_portainerRegistriesToAuthConfigs(t *testing.T) {
t.Parallel()
t.Run("returns empty slice for empty input", func(t *testing.T) {
t.Parallel()
result := portainerRegistriesToAuthConfigs([]portainer.Registry{})
require.Nil(t, result)
})
t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "registry.example.com", result[0].ServerAddress)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "secretkey",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "AWS:ecr-password",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "AWS", result[0].Username)
require.Equal(t, "ecr-password", result[0].Password)
})
t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false},
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "no-colon-token",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "valid.example.com", result[0].ServerAddress)
})
}
+1 -1
View File
@@ -61,7 +61,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
for _, registry := range registries {
if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
username, password, err := getEffectiveRegUsernamePassword(&registry)
if err != nil {
continue
}
+52 -21
View File
@@ -3,23 +3,42 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
// symlink traversal attacks from untrusted git repositories
type noSymlinkFS struct {
billy.Filesystem
}
func (fs noSymlinkFS) Symlink(_, _ string) error {
return gittypes.ErrSymlinkDetected
}
// NewNoSymlinkFS wraps fs and rejects any symlink creation
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
return noSymlinkFS{fs}
}
type gitClient struct {
preserveGitDirectory bool
}
@@ -30,8 +49,33 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err := git.CloneContext(ctx, storer, wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if c.preserveGitDirectory {
return nil
}
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
return nil
}
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
gitOptions := git.CloneOptions{
gitOptions := &git.CloneOptions{
URL: opt.repositoryUrl,
Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify,
@@ -43,23 +87,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
}
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if !c.preserveGitDirectory {
err := os.RemoveAll(filepath.Join(dst, ".git"))
if err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
}
return nil
return c.Download(ctx, dst, gitOptions)
}
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
@@ -73,11 +101,12 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
InsecureSkipTLS: opt.tlsSkipVerify,
}
refs, err := remote.List(listOptions)
refs, err := remote.ListContext(ctx, listOptions)
if err != nil {
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
}
return "", errors.Wrap(err, "failed to list repository refs")
}
@@ -159,6 +188,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
if ref.Name().String() == "HEAD" {
continue
}
ret = append(ret, ref.Name().String())
}
@@ -225,5 +255,6 @@ func checkGitError(err error) error {
} else if errMsg == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return err
}
+113 -3
View File
@@ -3,13 +3,17 @@ package git
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
@@ -18,7 +22,7 @@ import (
func setup(t *testing.T) string {
dir := t.TempDir()
bareRepoDir := filepath.Join(dir, "test-clone.git")
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
if err != nil {
@@ -53,7 +57,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
require.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git"))
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_cloneRepository(t *testing.T) {
@@ -146,6 +150,112 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
return count
}
func Test_noSymlinkFS_Symlink(t *testing.T) {
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
err := fs.Symlink("../../../etc/passwd", "evil-link")
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
dir := t.TempDir()
fs := NewNoSymlinkFS(osfs.New(dir))
f, err := fs.Create("test.txt")
require.NoError(t, err)
_, err = f.Write([]byte("hello"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
info, err := fs.Stat("test.txt")
require.NoError(t, err)
require.Equal(t, "test.txt", info.Name())
}
func createBareRepoWithSymlink(t *testing.T) string {
t.Helper()
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
repo, err := git.PlainInit(bareDir, true)
require.NoError(t, err)
storer := repo.Storer
fileBlob := &plumbing.MemoryObject{}
fileBlob.SetType(plumbing.BlobObject)
_, err = fileBlob.Write([]byte("hello world\n"))
require.NoError(t, err)
fileHash, err := storer.SetEncodedObject(fileBlob)
require.NoError(t, err)
symlinkBlob := &plumbing.MemoryObject{}
symlinkBlob.SetType(plumbing.BlobObject)
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
require.NoError(t, err)
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
require.NoError(t, err)
tree := &object.Tree{
Entries: []object.TreeEntry{
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
},
}
treeObj := &plumbing.MemoryObject{}
err = tree.Encode(treeObj)
require.NoError(t, err)
treeHash, err := storer.SetEncodedObject(treeObj)
require.NoError(t, err)
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
commit := &object.Commit{
Message: "add symlink",
Author: sig,
Committer: sig,
TreeHash: treeHash,
}
commitObj := &plumbing.MemoryObject{}
err = commit.Encode(commitObj)
require.NoError(t, err)
commitHash, err := storer.SetEncodedObject(commitObj)
require.NoError(t, err)
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
require.NoError(t, err)
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
require.NoError(t, err)
return bareDir
}
func Test_Download_RejectsSymlink(t *testing.T) {
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
URL: repoURL,
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
})
require.Error(t, err)
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_listRefsPrivateRepository(t *testing.T) {
ensureIntegrationTest(t)
+13 -1
View File
@@ -185,7 +185,19 @@ func (service *Service) LatestCommitID(
referenceName: referenceName,
}
return service.repoManager(options.baseOption).latestCommitID(context.TODO(), options)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
id, err := service.repoManager(options.baseOption).latestCommitID(ctx, options)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
log.Error().Str("url", repositoryURL).Msg("git fetch timed out after 1 minute")
}
return "", err
}
return id, nil
}
// ListRefs will list target repository's references without cloning the repository
+1
View File
@@ -7,6 +7,7 @@ import (
var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
type GitCredentialAuthType int
+6
View File
@@ -1,6 +1,7 @@
package update
import (
"os"
"strings"
"github.com/pkg/errors"
@@ -74,6 +75,11 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
}
if err := cloneGitRepository(gitService, cloneParams); err != nil {
if enableVersionFolder {
if removeErr := os.RemoveAll(toDir); removeErr != nil {
log.Warn().Err(removeErr).Str("dir", toDir).Msg("failed to remove partial clone directory")
}
}
return false, "", errors.WithMessagef(err, "failed to do a fresh clone of %v", objId)
}
@@ -2,8 +2,14 @@ package customtemplates
import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
return httperror.BadRequest("Invalid custom template identifier route variable", err)
}
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
var customTemplate *portainer.CustomTemplate
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
}
if canEdit || hasAccess {
return nil
}
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}); err != nil {
return response.TxErrorResponse(err)
}
entryPath := customTemplate.EntryPoint
@@ -0,0 +1,115 @@
package customtemplates
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateFile(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
templateContent := "some template content"
templateEntrypoint := "entrypoint"
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
2: portainer.AccessPolicy{RoleID: 0},
3: portainer.AccessPolicy{RoleID: 0},
}}))
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
// template 1
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
// template 2
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
r = mux.SetURLVars(r, map[string]string{"id": templateID})
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
return rr, handler.customTemplateFile(rr, r)
}
t.Run("unknown id should get not found error", func(t *testing.T) {
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
})
t.Run("admin should access adminonly template", func(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access adminonly template", func(t *testing.T) {
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
t.Run("std should access template via direct user access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should access template via team access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access template without access", func(t *testing.T) {
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
var customTemplate *portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
@@ -1,6 +1,7 @@
package containers
import (
"context"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -46,7 +47,7 @@ func (handler *Handler) recreate(w http.ResponseWriter, r *http.Request) *httper
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
newContainer, err := handler.containerService.Recreate(r.Context(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
newContainer, err := handler.containerService.Recreate(context.TODO(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
if err != nil {
return httperror.InternalServerError("Error recreating container", err)
}
+7 -1
View File
@@ -19,6 +19,7 @@ type StackViewModel struct {
Name string
IsExternal bool
Type portainer.StackType
Labels map[string]string
}
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
@@ -56,6 +57,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name,
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: container.Labels,
}
}
}
@@ -68,6 +70,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name,
IsExternal: true,
Type: portainer.DockerSwarmStack,
Labels: service.Spec.Labels,
}
}
}
@@ -79,7 +82,10 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
func(item StackViewModel) (*portainer.ResourceControl, error) {
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
if item.InternalStack != nil {
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
}
return uac.ExternalStackResourceControlGetter(tx, environmentID)(uac.ExternalStack{Labels: item.Labels})
},
)
}
@@ -8,7 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
dockerconsts "github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
@@ -28,12 +28,13 @@ func TestHandler_getDockerStacks(t *testing.T) {
containers := []types.Container{
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack1",
consts.ComposeStackNameLabel: "stack1",
},
},
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack2",
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
}
@@ -43,7 +44,7 @@ func TestHandler_getDockerStacks(t *testing.T) {
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{
Labels: map[string]string{
dockerconsts.SwarmStackNameLabel: "stack3",
consts.SwarmStackNameLabel: "stack3",
},
},
},
@@ -65,14 +66,16 @@ func TestHandler_getDockerStacks(t *testing.T) {
is.NoError(tx.Stack().Create(&stack1))
is.NoError(tx.Stack().Create(&portainer.Stack{
ID: 2,
Name: "stack2",
Name: "stack2", // stack 2 on env 2
EndpointID: 2,
Type: portainer.DockerSwarmStack,
}))
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
is.NoError(tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}))
return nil
}))
// testing admin user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: true,
@@ -93,11 +96,43 @@ func TestHandler_getDockerStacks(t *testing.T) {
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
{
Name: "stack3",
IsExternal: true,
Type: portainer.DockerSwarmStack,
Labels: map[string]string{
consts.SwarmStackNameLabel: "stack3",
},
},
}
assert.ElementsMatch(t, expectedStacks, stacksList)
return nil
}))
// testing standard user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 2,
}, environment.ID, containers, services)
require.NoError(t, err)
assert.Len(t, stacksList, 1)
expectedStacks := []StackViewModel{
{
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
}
@@ -39,6 +39,7 @@ const (
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
// @param endpointIds query []int false "will return only these environments(endpoints)"
// @param excludeIds query []int false "will exclude these environments(endpoints)"
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
// @param agentVersions query []string false "will return only environments with on of these agent versions"
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
+13
View File
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
excludeGroupIds []portainer.EndpointGroupID
edgeGroupIds []portainer.EdgeGroupID
excludeEdgeGroupIds []portainer.EdgeGroupID
}
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
}
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
excludeGroupIds: excludeGroupIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
})
}
if len(query.excludeGroupIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
})
}
if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
+40
View File
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
runTests(tests, t, handler, environments)
}
func Test_Filter_excludeGroupIDs(t *testing.T) {
groupA := portainer.EndpointGroupID(10)
groupB := portainer.EndpointGroupID(20)
groupC := portainer.EndpointGroupID(30)
endpoints := []portainer.Endpoint{
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
}
handler := setupFilterTest(t, endpoints)
tests := []filterTest{
{
title: "should exclude endpoints in groupA",
expected: []portainer.EndpointID{3, 4, 5},
query: EnvironmentsQuery{
excludeGroupIds: []portainer.EndpointGroupID{groupA},
},
},
{
title: "should exclude endpoints in groupA and groupB",
expected: []portainer.EndpointID{5},
query: EnvironmentsQuery{
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
},
},
{
title: "should return all endpoints when excludeGroupIds is empty",
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
query: EnvironmentsQuery{},
},
}
runTests(tests, t, handler, endpoints)
}
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
n := 10000
+1 -1
View File
@@ -61,7 +61,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/agent_versions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
h.Handle("/endpoints/relations", bouncer.RestrictedAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.39.0
// @version 2.39.3
// @description.markdown api-description.md
// @termsOfService
+1 -1
View File
@@ -113,7 +113,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/system", bouncer.AdminAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
+1 -1
View File
@@ -72,6 +72,7 @@ func (handler *Handler) initRouter(bouncer accessGuard) {
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
adminRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
// Use registry-specific access bouncer for inspect and repositories endpoints
registryAccessRouter := handler.NewRoute().Subrouter()
@@ -82,7 +83,6 @@ func (handler *Handler) initRouter(bouncer accessGuard) {
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.Handle("/registries/ping", httperror.LoggerHandler(handler.pingRegistry)).Methods(http.MethodPost)
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
}
type accessGuard interface {
-1
View File
@@ -28,7 +28,6 @@ import (
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
-1
View File
@@ -32,7 +32,6 @@ type execStartOperationPayload struct {
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 409
-1
View File
@@ -31,7 +31,6 @@ import (
// @param podName query string true "name of the pod containing the container"
// @param containerName query string true "name of the container"
// @param command query string true "command to execute in the container"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
-1
View File
@@ -18,7 +18,6 @@ import (
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
+63 -8
View File
@@ -170,18 +170,23 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
type PartialContainer struct {
HostConfig struct {
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Mounts []struct {
Type string `json:"Type"`
} `json:"Mounts"`
} `json:"HostConfig"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
Body: http.NoBody,
}
tokenData, err := security.RetrieveTokenData(request)
@@ -230,7 +235,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return nil, ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
for _, bind := range partialContainer.HostConfig.Binds {
if strings.HasPrefix(bind, "/") {
return forbiddenResponse, ErrBindMountsForbidden
@@ -238,6 +243,14 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
}
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
for _, mount := range partialContainer.HostConfig.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, ErrBindMountsForbidden
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
}
@@ -252,3 +265,45 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return response, err
}
func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
type PartialContainerUpdate struct {
Devices []any `json:"Devices"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialUpdate := &PartialContainerUpdate{}
if err := json.Unmarshal(body, partialUpdate); err != nil {
return nil, err
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
return forbiddenResponse, ErrDeviceMappingForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
@@ -0,0 +1,123 @@
package docker
import (
"bytes"
"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/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&admin)
require.NoError(t, err)
err = tx.User().Create(&regularUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "test",
SecuritySettings: portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
makeRequest := func(token portainer.TokenData, body any) *http.Request {
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
req = req.WithContext(security.StoreTokenData(req, &token))
return req
}
// Admin bypasses security checks
req := makeRequest(adminToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Binds with an absolute path is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Binds": []string{"/:/host:ro"},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with type bind is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with a non-bind type is allowed for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
}
+96 -26
View File
@@ -3,13 +3,13 @@ package docker
import (
"bytes"
"context"
"errors"
"io"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
@@ -18,6 +18,70 @@ import (
const serviceObjectIdentifier = "ID"
type partialServiceSpec struct {
TaskTemplate struct {
ContainerSpec struct {
CapabilityAdd []string `json:"CapabilityAdd"`
CapabilityDrop []string `json:"CapabilityDrop"`
Sysctls map[string]any `json:"Sysctls"`
Privileges *struct {
Seccomp *struct{ Mode string } `json:"Seccomp"`
AppArmor *struct{ Mode string } `json:"AppArmor"`
} `json:"Privileges"`
Mounts []struct {
Type string `json:"Type"`
VolumeOptions *struct {
DriverConfig *struct {
Options map[string]string `json:"Options"`
} `json:"DriverConfig"`
} `json:"VolumeOptions"`
} `json:"Mounts"`
} `json:"ContainerSpec"`
} `json:"TaskTemplate"`
}
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
spec := &partialServiceSpec{}
if err := json.Unmarshal(body, spec); err != nil {
return err
}
containerSpec := spec.TaskTemplate.ContainerSpec
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
return ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
return ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers {
for _, mount := range containerSpec.Mounts {
if mount.Type == "bind" {
return ErrBindMountsForbidden
}
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
return ErrBindMountsForbidden
}
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
if err != nil {
@@ -90,20 +154,6 @@ func selectorServiceLabels(responseObject map[string]any) map[string]any {
}
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
type PartialService struct {
TaskTemplate struct {
ContainerSpec struct {
Mounts []struct {
Type string
}
}
}
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
@@ -118,25 +168,45 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
return nil, err
}
body, err := io.ReadAll(request.Body)
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
}, err
}
return transport.replaceRegistryAuthenticationHeader(request)
}
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
partialService := &PartialService{}
if err := json.Unmarshal(body, partialService); err != nil {
if isAdminOrEndpointAdmin {
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, errors.New("forbidden to use bind mounts")
}
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
}, err
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.replaceRegistryAuthenticationHeader(request)
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
@@ -0,0 +1,522 @@
package docker
import (
"bytes"
"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/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const serviceCreationAPIVersion = "1.51"
type serviceCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
t.Helper()
const serviceID = "some-service-id"
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", serviceCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"ID": serviceID})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &serviceCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *serviceCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(spec)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
var (
restrictiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: false,
AllowSysctlSettingForRegularUsers: false,
AllowBindMountsForRegularUsers: false,
}
permissiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
}
)
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityDrop: []string{"MKNOD"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
var spec swarm.ServiceSpec
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: false,
})
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
CapabilityDrop: []string{"MKNOD"},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
Sysctls: map[string]string{"net.core.somaxconn": "128"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "tmpfs"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
@@ -22,6 +22,7 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/slicesx"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
@@ -109,6 +110,28 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
"volumes": (*Transport).proxyVolumeRequest,
}
type route struct {
method string
pattern *regexp.Regexp
}
var adminOnlyRoutes = []route{
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
}
func isAdminOnlyRoute(method string, path string) bool {
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
return method == r.method && r.pattern.MatchString(path)
})
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
@@ -137,6 +160,10 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
return proxyFunc(transport, request, unversionedPath)
}
if isAdminOnlyRoute(request.Method, unversionedPath) {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request)
}
@@ -261,6 +288,11 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
if action == "json" {
return transport.rewriteOperation(request, transport.containerInspectOperation)
}
if action == "update" {
return transport.decorateContainerUpdateOperation(request, containerID)
}
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
@@ -292,6 +324,11 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
action := path.Base(requestPath)
if action == "update" {
return transport.decorateServiceUpdateOperation(request, serviceID)
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
@@ -108,6 +108,141 @@ func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Serve
return srv, version
}
func TestTransport_adminProxy(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
// allowed routes
{http.MethodGet, "/plugins"}: nil,
{http.MethodGet, "/plugins/xxx/json"}: nil,
{http.MethodGet, "/plugins/privileges"}: nil,
// admin routes ; see `adminOnlyRoutes`
{http.MethodDelete, "/plugins/xxx"}: nil,
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
{http.MethodPost, "/plugins/xxx/disable"}: nil,
{http.MethodPost, "/plugins/pull"}: nil,
{http.MethodPost, "/plugins/xxx/push"}: nil,
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
{http.MethodPost, "/plugins/xxx/set"}: nil,
{http.MethodPost, "/plugins/create"}: nil,
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.ProxyDockerRequest(req)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/plugins", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_getRealResourceID(t *testing.T) {
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
+49
View File
@@ -1,9 +1,11 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
@@ -15,6 +17,7 @@ import (
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const volumeObjectIdentifier = "ResourceID"
@@ -122,12 +125,58 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels")
}
func CheckVolumeBodyRestrictions(request *http.Request) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
var volumeCreateBody struct {
DriverOpts map[string]string `json:"DriverOpts"`
}
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
return err
}
if volumeCreateBody.DriverOpts["type"] == "bind" {
return ErrBindMountsForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers {
if err := CheckVolumeBodyRestrictions(request); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
}, err
}
}
}
volumeID := request.Header.Get("X-Portainer-VolumeName")
if volumeID != "" {
@@ -0,0 +1,226 @@
package docker
import (
"bytes"
"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/docker/docker/api/types/volume"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const volumeCreationAPIVersion = "1.51"
type volumeCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
t.Helper()
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", volumeCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &volumeCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *volumeCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "evil-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
"o": "bind",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "admin-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: true,
})
body := volume.CreateOptions{
Name: "allowed-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/data",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "normal-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "tmpfs",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+7 -4
View File
@@ -2,6 +2,7 @@ package security
import (
"net/http"
"slices"
portainer "github.com/portainer/portainer/api"
)
@@ -80,12 +81,14 @@ func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl,
if teamAccessesCount > 0 {
for _, access := range resourceControl.TeamAccesses {
for _, membership := range context.UserMemberships {
if membership.TeamID == access.TeamID {
return true
}
if !slices.ContainsFunc(context.UserMemberships, func(m portainer.TeamMembership) bool {
return m.TeamID == access.TeamID
}) {
return false
}
}
return true
}
return false
+173
View File
@@ -0,0 +1,173 @@
package security
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
)
func TestAuthorizedResourceControlUpdate_AdminAlwaysAllowed(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
AdministratorsOnly: true,
}
ctx := &RestrictedRequestContext{IsAdmin: true}
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_PublicAlwaysAllowed(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
Public: true,
}
ctx := &RestrictedRequestContext{IsAdmin: false}
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_AdministratorsOnlyDenied(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
AdministratorsOnly: true,
UserAccesses: []portainer.UserResourceAccess{
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_EmptyAccessesDenied(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{}
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_UserAccessMatchingCurrentUser(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
UserAccesses: []portainer.UserResourceAccess{
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_UserAccessNotMatchingCurrentUser(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
UserAccesses: []portainer.UserResourceAccess{
{UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_TeamAccessUserMemberOfAllTeams(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
TeamAccesses: []portainer.TeamResourceAccess{
{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
{TeamID: 2, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{
IsAdmin: false,
UserMemberships: []portainer.TeamMembership{
{TeamID: 1},
{TeamID: 2},
},
}
require.True(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_TeamAccessUserNotMemberOfAllTeams(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
TeamAccesses: []portainer.TeamResourceAccess{
{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
{TeamID: 3, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{
IsAdmin: false,
UserMemberships: []portainer.TeamMembership{
{TeamID: 1},
{TeamID: 2},
},
}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_TeamAccessUserNotMemberOfAnyTeam(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
TeamAccesses: []portainer.TeamResourceAccess{
{TeamID: 5, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{
IsAdmin: false,
UserMemberships: []portainer.TeamMembership{
{TeamID: 1},
},
}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_MultipleUserAccessesDenied(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
UserAccesses: []portainer.UserResourceAccess{
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
{UserID: 2, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{IsAdmin: false, UserID: 1}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
func TestAuthorizedResourceControlUpdate_UserAndTeamAccessCombinationDenied(t *testing.T) {
t.Parallel()
rc := &portainer.ResourceControl{
UserAccesses: []portainer.UserResourceAccess{
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
},
TeamAccesses: []portainer.TeamResourceAccess{
{TeamID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
},
}
ctx := &RestrictedRequestContext{
IsAdmin: false,
UserID: 1,
UserMemberships: []portainer.TeamMembership{
{TeamID: 1},
},
}
require.False(t, AuthorizedResourceControlUpdate(rc, ctx))
}
+2 -14
View File
@@ -447,26 +447,14 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
return tokenData, nil
}
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
// extractBearerToken extracts the Bearer token from the Authorization header and returns the token.
func extractBearerToken(r *http.Request) (string, bool) {
// Token might be set via the "token" query parameter.
// For example, in websocket requests
// For these cases, hide the token from the query
query := r.URL.Query()
token := query.Get("token")
if token != "" {
query.Del("token")
r.URL.RawQuery = query.Encode()
return token, true
}
tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 {
return "", false
}
token = tokens[0]
token := tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
return token, true
+41 -6
View File
@@ -1,6 +1,8 @@
package registryutils
import (
"context"
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
@@ -14,9 +16,12 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
}
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
func fetchRegToken(registry *portainer.Registry) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx)
if err != nil {
return err
}
@@ -24,12 +29,34 @@ func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) er
registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix()
return nil
}
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if err := fetchRegToken(registry); err != nil {
return err
}
return tx.Registry().Update(registry.ID, registry)
}
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
// ValidateRegistriesECRTokens refreshes and persists ECR tokens for all registries that need it.
// Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention.
func ValidateRegistriesECRTokens(tx dataservices.DataStoreTx, registries []portainer.Registry) error {
for i := range registries {
reg := &registries[i]
if reg.Type != portainer.EcrRegistry {
continue
}
if isRegTokenValid(reg) {
continue
}
if err := doGetRegToken(tx, reg); err != nil {
return fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", reg.Name, err)
}
}
return nil
}
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
@@ -57,7 +84,15 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
password = registry.Password
if registry.Type == portainer.EcrRegistry {
username, password, err = parseRegToken(registry)
// Fallback token refresh in case the upstream caller did not pre-validate the token.
if !isRegTokenValid(registry) {
if err := fetchRegToken(registry); err != nil {
return "", "", fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", registry.Name, err)
}
}
username, password, err = ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
}
return
@@ -0,0 +1,131 @@
package registryutils_test
import (
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/stretchr/testify/require"
)
func newECRRegistry(id portainer.RegistryID, accessToken string, expiry int64) portainer.Registry {
return portainer.Registry{
ID: id,
Type: portainer.EcrRegistry,
Name: "test-ecr",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: accessToken,
AccessTokenExpiry: expiry,
}
}
func TestValidateRegistriesECRTokens(t *testing.T) {
t.Parallel()
t.Run("skips non-ECR registries without error", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
registries := []portainer.Registry{
{ID: 1, Type: portainer.DockerHubRegistry, Name: "dockerhub"},
{ID: 2, Type: portainer.CustomRegistry, Name: "custom"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, registries)
}))
})
t.Run("skips ECR registries with valid tokens", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
reg := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
}))
})
t.Run("returns nil for empty registry list", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{})
}))
})
t.Run("returns error for ECR registry with missing token", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
reg := newECRRegistry(1, "", 0)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Registry().Create(&reg)
}))
var validateErr error
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
return nil
})
require.Error(t, validateErr)
require.Contains(t, validateErr.Error(), "test-ecr")
})
t.Run("stops on first invalid ECR registry and includes its name in error", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
validECR := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
invalidECR := newECRRegistry(2, "", 0)
invalidECR.Name = "invalid-ecr"
nonECR := portainer.Registry{ID: 3, Type: portainer.DockerHubRegistry}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Registry().Create(&invalidECR)
}))
var validateErr error
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{validECR, invalidECR, nonECR})
return nil
})
require.Error(t, validateErr)
require.Contains(t, validateErr.Error(), "invalid-ecr")
})
}
func TestGetRegEffectiveCredential(t *testing.T) {
t.Parallel()
t.Run("returns username and password directly for non-ECR registry", func(t *testing.T) {
t.Parallel()
reg := &portainer.Registry{
Type: portainer.DockerHubRegistry,
Username: "user",
Password: "pass",
}
username, password, err := registryutils.GetRegEffectiveCredential(reg)
require.NoError(t, err)
require.Equal(t, "user", username)
require.Equal(t, "pass", password)
})
t.Run("parses ECR access token when token is valid", func(t *testing.T) {
t.Parallel()
reg := newECRRegistry(1, "AWS:ecr-password", time.Now().Add(time.Hour).Unix())
username, password, err := registryutils.GetRegEffectiveCredential(&reg)
require.NoError(t, err)
require.Equal(t, "AWS", username)
require.Equal(t, "ecr-password", password)
})
t.Run("returns error for ECR registry with missing token and invalid credentials", func(t *testing.T) {
t.Parallel()
reg := newECRRegistry(1, "", 0)
_, _, err := registryutils.GetRegEffectiveCredential(&reg)
require.Error(t, err)
require.Contains(t, err.Error(), "test-ecr")
})
}
+21
View File
@@ -147,6 +147,27 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
}
}
type stubSSLSettingsService struct {
settings *portainer.SSLSettings
}
func (s *stubSSLSettingsService) BucketName() string { return "ssl" }
func (s *stubSSLSettingsService) Settings() (*portainer.SSLSettings, error) {
return s.settings, nil
}
func (s *stubSSLSettingsService) UpdateSettings(settings *portainer.SSLSettings) error {
s.settings = settings
return nil
}
func WithSSLSettingsService(settings *portainer.SSLSettings) datastoreOption {
return func(d *testDatastore) {
d.sslSettings = &stubSSLSettingsService{settings: settings}
}
}
type stubUserService struct {
dataservices.UserService
+1 -1
View File
@@ -64,7 +64,7 @@ func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, err
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
jobs, err := kcl.getCronJobExecutions(cronJob.Name, cronJob.Namespace, jobsList)
if err != nil {
return models.K8sCronJob{}
}
+62
View File
@@ -5,7 +5,10 @@ import (
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
@@ -64,3 +67,62 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Logf("Deleted Cron Jobs")
})
}
// TestGetCronJobExecutionsNamespaceFilter verifies that getCronJobExecutions only returns
// executions belonging to the CronJob's own namespace, even when same-named CronJobs
// exist across multiple namespaces.
func TestGetCronJobExecutionsNamespaceFilter(t *testing.T) {
backoffLimit := int32(3)
completions := int32(1)
makeJob := func(name, namespace, cronJobName string) batchv1.Job {
return batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
OwnerReferences: []metav1.OwnerReference{
{Kind: "CronJob", Name: cronJobName},
},
},
Spec: batchv1.JobSpec{
BackoffLimit: &backoffLimit,
Completions: &completions,
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "worker", Image: "busybox"}},
},
},
},
}
}
// Simulate the cross-namespace job list returned when fetchCronJobs is called with namespace=""
allJobs := &batchv1.JobList{
Items: []batchv1.Job{
makeJob("backup-prod-28001440", "ns-prod", "backup"),
makeJob("backup-test-28001441", "ns-test", "backup"),
},
}
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
isKubeAdmin: true,
}
t.Run("returns only executions from the matching namespace", func(t *testing.T) {
result, err := kcl.getCronJobExecutions("backup", "ns-prod", allJobs)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "ns-prod", result[0].Namespace)
assert.Equal(t, "backup-prod-28001440", result[0].Name)
})
t.Run("returns only executions from the other matching namespace", func(t *testing.T) {
result, err := kcl.getCronJobExecutions("backup", "ns-test", allJobs)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "ns-test", result[0].Namespace)
assert.Equal(t, "backup-test-28001441", result[0].Name)
})
}
+26 -10
View File
@@ -3,8 +3,10 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true,
}, scheme.ParameterCodec)
streamOpts := remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
}
// Try WebSocket executor first, fall back to SPDY if it fails
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config,
"GET", // WebSocket uses GET for the upgrade request
req.URL().String(),
channelProtocolList...,
)
if err != nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- err
if err == nil {
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err == nil {
return
}
log.Warn().
Err(err).
Str("context", "StartExecProcess").
Msg("WebSocket exec failed, falling back to SPDY")
}
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
})
// Fall back to SPDY executor
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
return
}
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil {
var exitError utilexec.ExitError
if !errors.As(err, &exitError) {
errChan <- errors.New("unable to start exec process")
errChan <- fmt.Errorf("unable to start exec process: %w", err)
}
}
}
+5 -1
View File
@@ -168,11 +168,15 @@ func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
// getCronJobExecutions returns the jobs for a given cronjob
// it returns the jobs for the cronjob
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, cronJobNamespace string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
maxItems := 5
results := make([]models.K8sJob, 0)
for _, job := range jobs.Items {
if job.Namespace != cronJobNamespace {
continue
}
for _, owner := range job.OwnerReferences {
if owner.Kind == "CronJob" && owner.Name == cronJobName {
results = append(results, kcl.parseJob(job))
-1
View File
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
name: portainer-ctx
current-context: portainer-ctx
kind: Config
preferences: {}
users:
- name: test-user
user:
+5 -5
View File
@@ -73,7 +73,7 @@ func (Service) AuthenticateUser(username, password string, settings *portainer.L
if err != nil {
return err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -108,7 +108,7 @@ func (Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
if err != nil {
return nil, err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -133,7 +133,7 @@ func (Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
if err != nil {
return nil, err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -182,7 +182,7 @@ func (Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPU
if err != nil {
return nil, err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -309,7 +309,7 @@ func (Service) TestConnectivity(settings *portainer.LDAPSettings) error {
if err != nil {
return err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
+2 -2
View File
@@ -33,7 +33,7 @@ func TestCreateConnectionForURL(t *testing.T) {
conn, err := createConnectionForURL(settings.URL, settings)
require.NoError(t, err)
require.NotNil(t, conn)
conn.Close()
require.NoError(t, conn.Close())
// TLS
@@ -45,7 +45,7 @@ func TestCreateConnectionForURL(t *testing.T) {
conn, err = createConnectionForURL(settings.URL, settings)
require.NoError(t, err)
require.NotNil(t, conn)
conn.Close()
require.NoError(t, conn.Close())
// Invalid TLS
+4 -2
View File
@@ -141,6 +141,7 @@ type (
LogLevel *string
LogMode *string
KubectlShellImage *string
KubectlShellImageSet bool
PullLimitCheckDisabled *bool
TrustedOrigins *string
}
@@ -576,7 +577,7 @@ type (
}
PolicyChartBundle struct {
PolicyChartSummary
PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
@@ -1426,6 +1427,7 @@ type (
LastActivity time.Time
Port int
Credentials string
HasSnapshot bool
}
// TunnelServerInfo represents information associated to the tunnel server
@@ -1874,7 +1876,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.39.0"
APIVersion = "2.39.3"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
+2 -10
View File
@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
}
}
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
}); err != nil {
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
}
return err
}
return nil
})
}
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
@@ -44,6 +45,10 @@ func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityC
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
return nil, err
}
config := &ComposeStackDeploymentConfig{
stack: stack,
endpoint: endpoint,
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
)
@@ -43,6 +44,10 @@ func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityCon
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
return nil, err
}
config := &SwarmStackDeploymentConfig{
stack: stack,
endpoint: endpoint,
+34
View File
@@ -0,0 +1,34 @@
package stackutils
import (
"maps"
"os"
"path"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/compose-spec/compose-go/v2/dotenv"
)
// BuildEnvMap builds the environment variable map for stack validation/loading.
// Priority (lowest to highest): OS env → .env file → stack.Env
func BuildEnvMap(stack *portainer.Stack) map[string]string {
env := make(map[string]string, len(os.Environ()))
for _, e := range os.Environ() {
k, v, _ := strings.Cut(e, "=")
env[k] = v
}
dotEnvPath := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
if dotVars, err := dotenv.Read(dotEnvPath); err == nil {
maps.Copy(env, dotVars)
}
for _, pair := range stack.Env {
env[pair.Name] = pair.Value
}
return env
}
+38 -30
View File
@@ -1,38 +1,38 @@
package stackutils
import (
portainer "github.com/portainer/portainer/api"
"context"
"path"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/types"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
composeloader "github.com/compose-spec/compose-go/v2/loader"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/pkg/errors"
)
func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
type StackFileValidationConfig struct {
Content []byte
SecuritySettings *portainer.EndpointSecuritySettings
Env map[string]string
WorkingDir string
}
func IsValidStackFile(config StackFileValidationConfig) error {
composeConfigDetails := composetypes.ConfigDetails{
ConfigFiles: []composetypes.ConfigFile{{Content: config.Content}},
Environment: config.Env,
WorkingDir: config.WorkingDir,
}
composeConfig, err := composeloader.LoadWithContext(context.Background(), composeConfigDetails, composeloader.WithSkipValidation)
if err != nil {
return err
}
composeConfigFile := types.ConfigFile{
Config: composeConfigYAML,
}
composeConfigDetails := types.ConfigDetails{
ConfigFiles: []types.ConfigFile{composeConfigFile},
Environment: map[string]string{},
}
composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) {
options.SkipValidation = true
})
if err != nil {
return err
}
for key := range composeConfig.Services {
service := composeConfig.Services[key]
if !securitySettings.AllowBindMountsForRegularUsers {
for _, service := range composeConfig.Services {
if !config.SecuritySettings.AllowBindMountsForRegularUsers {
for _, volume := range service.Volumes {
if volume.Type == "bind" {
return errors.New("bind-mount disabled for non administrator users")
@@ -40,23 +40,23 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
}
}
if !securitySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
if !config.SecuritySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
return errors.New("privileged mode disabled for non administrator users")
}
if !securitySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
if !config.SecuritySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
return errors.New("pid host disabled for non administrator users")
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
if !config.SecuritySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
return errors.New("device mapping disabled for non administrator users")
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
if !config.SecuritySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
return errors.New("sysctl setting disabled for non administrator users")
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
if !config.SecuritySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
return errors.New("container capabilities disabled for non administrator users")
}
}
@@ -65,13 +65,21 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
}
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
env := BuildEnvMap(stack)
workingDir := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint))
for _, file := range GetStackFilePaths(stack, false) {
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
if err != nil {
return errors.Wrap(err, "failed to get stack file content")
}
if err := IsValidStackFile(stackContent, securitySettings); err != nil {
if err := IsValidStackFile(StackFileValidationConfig{
Content: stackContent,
SecuritySettings: securitySettings,
Env: env,
WorkingDir: workingDir,
}); err != nil {
return errors.Wrap(err, "stack config file is invalid")
}
}
+225 -20
View File
@@ -1,10 +1,11 @@
package stackutils
import (
"os"
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
)
@@ -30,32 +31,236 @@ networks:
`)
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(yamlContent, securitySettings)
err := IsValidStackFile(StackFileValidationConfig{
Content: yamlContent,
SecuritySettings: securitySettings,
})
require.NoError(t, err)
}
func TestIsValidStackFile_PortEnv(t *testing.T) {
yamlContent := []byte(`
// TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects
// validation when the env var is not provided. Docker accepts an empty host port (left side)
// but requires a valid container port (right side).
func TestIsValidStackFile_MissingEnvVarBehavior(t *testing.T) {
securitySettings := &portainer.EndpointSecuritySettings{}
t.Run("var on left side only passes (docker allows :9090)", func(t *testing.T) {
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "${API_PORT}:9090"
`),
SecuritySettings: securitySettings,
})
require.NoError(t, err)
})
t.Run("var on right side fails", func(t *testing.T) {
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "9090:${API_PORT}"
`),
SecuritySettings: securitySettings,
})
require.Error(t, err)
})
t.Run("var on both sides fails", func(t *testing.T) {
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "${API_PORT}:${API_PORT}"
`),
SecuritySettings: securitySettings,
})
require.Error(t, err)
})
}
func TestIsValidStackFile_EnvVarInBothPortFields(t *testing.T) {
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
webservice:
api:
image: nginx
container_name: hello-world
networks:
- "mynet1"
ports:
- "${PORT}:80"
networks:
mynet1:
driver: bridge
ipam:
config:
- subnet: 172.16.0.0/24
`)
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(yamlContent, securitySettings)
- "${API_PORT}:${API_PORT}"
`),
SecuritySettings: securitySettings,
Env: map[string]string{"API_PORT": "3000"},
})
require.NoError(t, err)
}
type mockFileService struct {
portainer.FileService
fileContent []byte
projectVersionPath string
}
func (m mockFileService) GetFileContent(trustedRootPath, filePath string) ([]byte, error) {
return m.fileContent, nil
}
func (m mockFileService) FormProjectPathByVersion(projectPath string, version int, commitHash string) string {
return m.projectVersionPath
}
func TestValidateStackFiles_EnvVars(t *testing.T) {
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "${API_PORT}:${API_PORT}"
`)
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "docker-compose.yml",
Env: []portainer.Pair{{Name: "API_PORT", Value: "3000"}},
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: "/tmp/stack/1",
}
securitySettings := &portainer.EndpointSecuritySettings{}
err := ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_OSEnvVar(t *testing.T) {
t.Setenv("HOST_PORT", "3000")
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "80:${HOST_PORT}"
`)
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: "/tmp/stack/1",
}
securitySettings := &portainer.EndpointSecuritySettings{}
err := ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_DotEnvFile(t *testing.T) {
tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0600)
require.NoError(t, err)
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "80:${HOST_PORT}"
`)
stack := &portainer.Stack{
ProjectPath: tmpDir,
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: tmpDir,
}
securitySettings := &portainer.EndpointSecuritySettings{}
err = ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_EnvFileAttribute(t *testing.T) {
tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0600)
require.NoError(t, err)
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
env_file:
- ./web.env
`)
stack := &portainer.Stack{
ProjectPath: tmpDir,
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: tmpDir,
}
securitySettings := &portainer.EndpointSecuritySettings{}
err = ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_BindMountBlockedForNonAdmin(t *testing.T) {
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
volumes:
- /host/path:/container/path
`)
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: "/tmp/stack/1",
}
securitySettings := &portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
}
err := ValidateStackFiles(stack, securitySettings, fileService)
require.ErrorContains(t, err, "bind-mount disabled for non administrator users")
}
+21
View File
@@ -25,3 +25,24 @@ func StackResourceControlGetter[
func StackResourceControlID(endpointID portainer.EndpointID, name string) string {
return stackutils.ResourceControlID(endpointID, name)
}
type ExternalStack struct {
Labels map[string]string
}
// External stacks are indirectly detected either via containers or services labels
// Any UAC applied to them can only be fetched from containers'/services' labels
func ExternalStackResourceControlGetter[
TX txLike[RCS, TS, US],
RCS rcServiceLike,
TS teamServiceLike,
US userServiceLike,
](
tx TX,
endpointID portainer.EndpointID) func(item ExternalStack) (*portainer.ResourceControl, error) {
return genericResourcControlGetter(tx, endpointID, ResourceContext[ExternalStack]{
RCType: portainer.StackResourceControl,
IDGetter: func(s ExternalStack) string { return "0" },
LabelsGetter: func(es ExternalStack) map[string]string { return es.Labels },
})
}
@@ -358,7 +358,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [
input.ExtraNetworks.forEach(function (network) {
networks.push({ Target: network.Name });
});
config.Networks = _.uniqWith(networks, _.isEqual);
config.TaskTemplate.Networks = _.uniqWith(networks, _.isEqual);
}
function prepareHostsEntries(config, input) {
@@ -13,7 +13,7 @@ export interface StandaloneFileContentPayload {
stackFileContent: string;
/** List of environment variables */
env?: Array<Pair>;
env?: Array<Pair> | null;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
@@ -13,7 +13,7 @@ export interface SwarmFileContentPayload {
stackFileContent: string;
/** List of environment variables */
env?: Array<Pair>;
env?: Array<Pair> | null;
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
+2 -2
View File
@@ -54,7 +54,7 @@ export interface Stack {
EndpointId: number;
SwarmId: string;
EntryPoint: string;
Env: EnvVar[];
Env: EnvVar[] | null;
ResourceControl?: ResourceControlResponse;
Status: StackStatus;
ProjectPath: string;
@@ -84,7 +84,7 @@ export type StackFile = {
};
export interface GitStackPayload {
env: Array<EnvVar>;
env: Array<EnvVar> | null;
prune?: boolean;
RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean;
@@ -66,7 +66,6 @@
.pagination > .active > span:focus,
.pagination > .active > button:focus {
@apply text-blue-7;
z-index: 3;
cursor: default;
/* background-color: var(--text-pagination-span-color); */
background-color: var(--bg-pagination-color);
+2 -2
View File
@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={clsx(
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
@@ -76,7 +76,7 @@ const SheetOverlay = forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
'fixed z-50 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
@@ -15,11 +15,10 @@ export function StickyFooter({
<div
className={clsx(
styles.actionBar,
// The sticky footer should be below the modal overlay `Modal.tsx` and react select menu `ReactSelect.css` (z-50)
'fixed bottom-0 right-0 z-10 h-16',
'fixed bottom-0 right-0 z-40 h-16',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]',
className
)}
>
+12 -3
View File
@@ -1,12 +1,13 @@
import { Trash2 } from 'lucide-react';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { confirmDelete } from '@@/modals/confirm';
import { Button } from './Button';
import { LoadingButton } from './LoadingButton';
import { Button } from './Button';
type ConfirmOrClick =
| {
@@ -26,7 +27,10 @@ export function DeleteButton({
size,
children,
isLoading,
text = 'Remove',
loadingText = 'Removing...',
icon = false,
type,
'data-cy': dataCy,
...props
}: PropsWithChildren<
@@ -35,7 +39,10 @@ export function DeleteButton({
size?: ComponentProps<typeof Button>['size'];
disabled?: boolean;
isLoading?: boolean;
text?: string;
loadingText?: string;
icon?: boolean;
type?: ComponentProps<typeof Button>['type'];
}
>) {
if (isLoading === undefined) {
@@ -46,10 +53,11 @@ export function DeleteButton({
disabled={disabled || isLoading}
onClick={() => handleClick()}
icon={Trash2}
className="!m-0"
className={clsx('!m-0', icon ? 'btn-icon' : '')}
data-cy={dataCy}
type={type}
>
{children || 'Remove'}
{children || text}
</Button>
);
}
@@ -65,6 +73,7 @@ export function DeleteButton({
data-cy={dataCy}
isLoading={isLoading}
loadingText={loadingText}
type={type}
>
{children || 'Remove'}
</LoadingButton>
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { difference } from 'lodash';
import {
getCoreRowModel,
getFilteredRowModel,
@@ -14,6 +16,7 @@ import { defaultGetRowId } from './defaultGetRowId';
import { Table } from './Table';
import { NestedTable } from './NestedTable';
import { DatatableContent } from './DatatableContent';
import { DatatableFooter } from './DatatableFooter';
import { BasicTableSettings, DefaultType } from './types';
interface Props<D extends DefaultType> extends AutomationTestingProps {
@@ -69,6 +72,18 @@ export function NestedDatatable<D extends DefaultType>({
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
});
const tableState = tableInstance.getState();
const selectedRowModel = tableInstance.getSelectedRowModel();
const selectedItems = selectedRowModel.rows.map((row) => row.original);
const filteredItems = tableInstance
.getFilteredRowModel()
.rows.map((row) => row.original);
const hiddenSelectedItems = useMemo(
() => difference(selectedItems, filteredItems),
[selectedItems, filteredItems]
);
return (
<NestedTable>
<Table.Container noWidget>
@@ -80,6 +95,17 @@ export function NestedDatatable<D extends DefaultType>({
aria-label={ariaLabel}
data-cy={dataCy}
/>
{enablePagination && (
<DatatableFooter
onPageChange={tableInstance.setPageIndex}
onPageSizeChange={tableInstance.setPageSize}
page={tableState.pagination.pageIndex}
pageSize={tableState.pagination.pageSize}
pageCount={tableInstance.getPageCount()}
totalSelected={selectedItems.length}
totalHiddenSelected={hiddenSelectedItems.length}
/>
)}
</Table.Container>
</NestedTable>
);
+21 -9
View File
@@ -7,31 +7,43 @@ interface Props<D extends DefaultType = DefaultType> {
cells: Cell<D, unknown>[];
className?: string;
onClick?: () => void;
'aria-selected'?: boolean;
}
export function TableRow<D extends DefaultType = DefaultType>({
cells,
className,
onClick,
'aria-selected': ariaSelected,
}: Props<D>) {
return (
<tr
className={clsx(className, { 'cursor-pointer': !!onClick })}
onClick={onClick}
aria-selected={ariaSelected}
>
{cells.map((cell) => (
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{cells.map((cell) => {
const { className, width } = parseMeta(cell.column.columnDef.meta);
return (
<td key={cell.id} className={className} style={{ width }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
function getClassName<D extends DefaultType = DefaultType>(
function parseMeta<D extends DefaultType = DefaultType>(
meta: ColumnMeta<D, unknown> | undefined
) {
return !!meta && 'className' in meta && typeof meta.className === 'string'
? meta.className
: '';
const className =
!!meta && 'className' in meta && typeof meta.className === 'string'
? meta.className
: '';
const width =
!!meta && 'width' in meta && typeof meta.width === 'string'
? meta.width
: undefined;
return { className, width };
}
@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
),
enableHiding: false,
meta: {
width: 50,
width: '50px',
},
};
}
@@ -52,7 +52,7 @@ export function FormSection({
</FormSectionTitle>
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
{isExpanded && <div className="clear-both">{children}</div>}
<div className="clear-both">{isExpanded && children}</div>
</div>
);
}
+4 -4
View File
@@ -37,11 +37,11 @@ export function Modal({
<Context.Provider value>
<DialogOverlay
isOpen
className={clsx(
styles.overlay,
'flex items-center justify-center z-50'
)}
className={clsx(styles.overlay, 'flex items-center justify-center')}
onDismiss={onDismiss}
// When a Sheet is open and then a Modal opens, Radix DismissableLayer sets body.style.pointerEvents="none" for this modal overlay, so make it auto here.
// z-index ensures the modal renders above the base views and any Sheet (z-50).
style={{ zIndex: 60, pointerEvents: 'auto' }}
>
<DialogContent
aria-label={ariaLabel}
@@ -6,14 +6,14 @@
.background-error {
padding-top: 55px;
background-image: url(~assets/images/icon-error.svg);
background-image: url(~@/assets/images/icon-error.svg);
background-repeat: no-repeat;
background-position: top left;
}
.background-warning {
padding-top: 55px;
background-image: url(~assets/images/icon-warning.svg);
background-image: url(~@/assets/images/icon-warning.svg);
background-repeat: no-repeat;
background-position: top left;
}
@@ -44,7 +44,7 @@ export function HealthStatus({ health }: Props) {
<div className="vertical-center">{health.FailingStreak}</div>
</DetailsTable.Row>
{!!health.Log && (
{!!health.Log?.length && (
<DetailsTable.Row label="Last output">
{health.Log[health.Log.length - 1].Output}
</DetailsTable.Row>
@@ -5,7 +5,7 @@ import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
import { ResourceControlType } from '@/react/portainer/access-control/types';
import { trimContainerName } from '@/docker/filters/utils';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { Registry } from '@/react/portainer/registries/types/registry';
import { PageHeader } from '@@/PageHeader';
@@ -32,7 +32,7 @@ export function ItemView() {
{ select: (c) => new ContainerDetailsViewModel(c) }
);
const registriesQuery = useRegistries();
const registriesQuery = useEnvironmentRegistries(environmentId);
if (
containerQuery.isLoading ||
@@ -25,6 +25,7 @@ export function TasksDatatable({
search={search}
aria-label="Tasks table"
data-cy="docker-service-tasks-nested-datatable"
initialSortBy={{ id: 'Updated', desc: true }}
/>
);
}
@@ -57,7 +57,7 @@ export function StackEditorTab({
);
const initialValues: StackEditorFormValues = {
environmentVariables: stack.Env,
environmentVariables: stack.Env || [],
prune: !!(stack.Option && stack.Option.Prune),
stackFileContent: originalFileContent,
enabledWebhook: !!stack.Webhook,
@@ -117,6 +117,7 @@ export function StackEditorTab({
envType={envType}
schema={schemaQuery.data}
versions={versions}
isSubmitting={mutation.isLoading}
isSaved={mutation.isSuccess}
webhookId={webhookId}
/>
@@ -43,6 +43,7 @@ const defaultProps = {
schema: { type: 'object' } as JSONSchema7,
isOrphaned: false,
stackId: 1,
isSubmitting: false,
isSaved: false,
webhookId: '',
};
@@ -377,17 +378,7 @@ describe('form submission', () => {
});
it('should show loading text during submission', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(() => {})); // Never resolves
renderComponent({}, { onSubmit });
const user = userEvent.setup();
await waitFor(() => {
const deployButton = screen.getByTestId('stack-deploy-button');
expect(deployButton).toBeEnabled();
});
const deployButton = screen.getByTestId('stack-deploy-button');
await user.click(deployButton);
renderComponent({ isSubmitting: true }, {});
await waitFor(() => {
expect(screen.getByText(/Deployment in progress.../)).toBeInTheDocument();
@@ -29,6 +29,7 @@ interface StackEditorTabInnerProps {
versions?: Array<number>;
stackId: Stack['Id'];
isSaved: boolean;
isSubmitting: boolean;
webhookId: string;
}
@@ -42,20 +43,15 @@ export function StackEditorTabInner({
versions,
stackId,
isSaved,
isSubmitting,
webhookId,
}: StackEditorTabInnerProps) {
const { authorized: isAuthorizedToUpdate } = useAuthorizations(
'PortainerStackUpdate'
);
const {
values,
errors,
setFieldValue,
isSubmitting,
isValid,
initialValues,
} = useFormikContext<StackEditorFormValues>();
const { values, errors, setFieldValue, isValid, initialValues } =
useFormikContext<StackEditorFormValues>();
usePreventExit(
initialValues.stackFileContent,
@@ -173,24 +173,19 @@ describe('getEnvironmentOptions', () => {
});
});
it('should create "Unassigned" group for GroupId = 1', () => {
it('should auto create an Others group if group is missing', () => {
const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 1 } as Environment,
{ Id: 2, Name: 'Env 2', GroupId: 2 } as Environment,
];
const groups: EnvironmentGroup[] = [];
const result = getEnvironmentOptions([], environments);
expect(result[0].label).toBe('Unassigned');
expect(result[0].options[0]).toEqual({ label: 'Env 1', value: 1 });
});
it('should throw error if group is missing for non-unassigned GroupId', () => {
const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 2 } as Environment,
];
expect(() => getEnvironmentOptions([], environments)).toThrow(
'Missing group with id 2'
);
const result = getEnvironmentOptions(groups, environments);
expect(result.length).toBe(1);
expect(result[0].label).toBe('Others');
expect(result[0].options).toEqual([
{ label: 'Env 1', value: 1 },
{ label: 'Env 2', value: 2 },
]);
});
});
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { sortBy } from 'lodash';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
@@ -73,7 +74,11 @@ export function getEnvironmentOptions(
return acc;
}
const groupId = environment.GroupId;
let groupId = environment.GroupId;
if (!groups.some((g) => g.Id === groupId)) {
groupId = -1;
}
if (!acc[groupId]) {
acc[groupId] = [];
}
@@ -87,13 +92,10 @@ export function getEnvironmentOptions(
return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => {
const parsedGroupId = parseInt(groupId, 10);
const group = groups.find((g) => g.Id === parsedGroupId);
if (!group && parsedGroupId !== 1) {
throw new Error(`Missing group with id ${groupId}`);
}
return {
label: group?.Name || 'Unassigned',
options: envOptions,
label: group?.Name || 'Others',
options: sortBy(envOptions, 'label'),
};
});
}
@@ -25,7 +25,7 @@ export async function duplicateStack({
fileContent: string;
targetEnvironmentId: EnvironmentId;
type: StackType;
env?: Array<Pair>;
env?: Array<Pair> | null;
}) {
if (type === StackType.DockerSwarm) {
const swarm = await getSwarm(targetEnvironmentId);
@@ -46,7 +46,7 @@ export function StackRedeployGitForm({ stack }: { stack: Stack }) {
SaveCredential: false,
},
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
env: stack.Env,
env: stack.Env || [],
prune: stack.Option?.Prune || false,
refName: stack.GitConfig?.ReferenceName || '',
tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false,
+1 -1
View File
@@ -15,7 +15,7 @@ export function useUpdateStackMutation() {
type Payload = {
stackFileContent: string;
env?: EnvVarValues;
env?: EnvVarValues | null;
prune?: boolean;
webhook?: string;
repullImageAndRedeploy?: boolean;
@@ -120,7 +120,7 @@ async function createStackAndGitCredential(
autoUpdate: AutoUpdateResponse | null;
}
) {
const newGitModel = await saveGitCredentialsIfNeeded(userId, payload.git);
const resolvedAuth = await saveGitCredentialsIfNeeded(userId, payload.git);
return createStackFromGit({
deploymentType: payload.deploymentType,
@@ -132,13 +132,13 @@ async function createStackAndGitCredential(
retryDeploy: payload.retryDeploy,
staggerConfig: payload.staggerConfig,
useManifestNamespaces: payload.useManifestNamespaces,
repositoryUrl: newGitModel.RepositoryURL,
repositoryReferenceName: newGitModel.RepositoryReferenceName,
filePathInRepository: newGitModel.ComposeFilePathInRepository,
repositoryAuthentication: newGitModel.RepositoryAuthentication,
repositoryUsername: newGitModel.RepositoryUsername,
repositoryPassword: newGitModel.RepositoryPassword,
repositoryGitCredentialId: newGitModel.RepositoryGitCredentialID,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
filePathInRepository: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: resolvedAuth.RepositoryAuthentication,
repositoryUsername: resolvedAuth.RepositoryUsername,
repositoryPassword: resolvedAuth.RepositoryPassword,
repositoryGitCredentialId: resolvedAuth.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
perDeviceConfigsGroupMatchType:
@@ -146,7 +146,7 @@ async function createStackAndGitCredential(
perDeviceConfigsMatchType:
payload.relativePathSettings?.PerDeviceConfigsMatchType,
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
tlsSkipVerify: newGitModel.TLSSkipVerify,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.autoUpdate,
});
}
@@ -1,14 +1,11 @@
import { Pencil } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { Pod } from 'kubernetes-types/core/v1';
import { Authorized, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { Authorized } from '@/react/hooks/useUser';
import { useNamespaceQuery } from '@/react/kubernetes/namespaces/queries/useNamespaceQuery';
import { Widget, WidgetBody } from '@@/Widget';
import { AddButton, Button } from '@@/buttons';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
import { AddButton } from '@@/buttons';
import { applicationIsKind, isExternalApplication } from '../../utils';
import { appStackIdLabel, appStackKindLabel } from '../../constants';
@@ -17,6 +14,8 @@ import { useApplicationServices } from '../../queries/useApplicationServices';
import { useAppStackFile } from '../../queries/useAppStackFile';
import { Application } from '../../types';
import { EdgeEditButton } from './EdgeEditButton';
import { EditButton } from './EditButton';
import { RestartApplicationButton } from './RestartApplicationButton';
import { RedeployApplicationButton } from './RedeployApplicationButton';
import { RollbackApplicationButton } from './RollbackApplicationButton';
@@ -42,9 +41,6 @@ export function ApplicationDetailsWidget() {
const namespaceData = useNamespaceQuery(environmentId, namespace);
const isSystemNamespace = namespaceData.data?.IsSystem;
// check if user is edge admin
const edgeAdminQuery = useIsEdgeAdmin();
// get app info
const { data: app } = useApplication(
environmentId,
@@ -81,35 +77,15 @@ export function ApplicationDetailsWidget() {
{!isSystemNamespace && (
<div className="mb-4 flex flex-wrap gap-2">
<Authorized authorizations="K8sApplicationDetailsW">
<Link
to={
appStackKind === 'edge'
? 'edge.stacks.edit'
: 'kubernetes.applications.application.edit'
}
params={
appStackKind === 'edge'
? { stackId: appStackId }
: undefined
}
data-cy="k8sAppDetail-editAppLink"
>
<Button
type="button"
color="light"
size="small"
className="hover:decoration-none !ml-0"
data-cy="k8sAppDetail-editAppButton"
disabled={
edgeAdminQuery.isLoading || !edgeAdminQuery.isAdmin
}
>
<Icon icon={Pencil} className="mr-1" />
{appStackKind === 'edge' ? (
<EdgeEditButton stackId={appStackId} />
) : (
<EditButton to=".edit">
{externalApp
? 'Edit external application'
: 'Edit this application'}
</Button>
</Link>
</EditButton>
)}
</Authorized>
{!applicationIsKind<Pod>('Pod', app) && (
<>
@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { EdgeEditButton } from './EdgeEditButton';
const mockUseIsEdgeAdmin = vi.fn();
vi.mock('@/react/hooks/useUser', () => ({
useIsEdgeAdmin: () => mockUseIsEdgeAdmin(),
}));
vi.mock('@@/Tip/TooltipWithChildren', () => ({
TooltipWithChildren: ({
children,
message,
}: {
children: React.ReactNode;
message: string;
}) => (
<div data-cy="tooltip" data-message={message}>
{children}
</div>
),
}));
vi.mock('@@/buttons', () => ({
Button: ({
children,
disabled,
'data-cy': dataCy,
}: {
children: React.ReactNode;
disabled?: boolean;
'data-cy'?: string;
}) => (
<button disabled={disabled} data-cy={dataCy} type="button">
{children}
</button>
),
}));
vi.mock('@@/Link', () => ({
Link: ({ children }: { children: React.ReactNode }) => (
<a href="/">{children}</a>
),
}));
describe('EdgeEditButton', () => {
it('renders disabled button without tooltip while loading', () => {
mockUseIsEdgeAdmin.mockReturnValue({ isLoading: true, isAdmin: false });
render(<EdgeEditButton stackId={1} />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument();
});
it('renders enabled button without tooltip for edge admin', () => {
mockUseIsEdgeAdmin.mockReturnValue({ isLoading: false, isAdmin: true });
render(<EdgeEditButton stackId={1} />);
const button = screen.getByRole('button');
expect(button).not.toBeDisabled();
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument();
});
it('renders disabled button with tooltip for non-admin', () => {
mockUseIsEdgeAdmin.mockReturnValue({ isLoading: false, isAdmin: false });
render(<EdgeEditButton stackId={1} />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
const tooltip = screen.getByTestId('tooltip');
expect(tooltip).toHaveAttribute(
'data-message',
'This application is managed by an edge stack and can only be edited by an edge administrator'
);
});
});
@@ -0,0 +1,35 @@
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { EditButton } from './EditButton';
interface Props {
stackId?: number;
}
export function EdgeEditButton({ stackId }: Props) {
const edgeAdminQuery = useIsEdgeAdmin();
const isDisabled = edgeAdminQuery.isLoading || !edgeAdminQuery.isAdmin;
const button = (
<EditButton
to="edge.stacks.edit"
params={{ stackId }}
disabled={isDisabled}
>
Manage edge stack
</EditButton>
);
if (edgeAdminQuery.isLoading || edgeAdminQuery.isAdmin) {
return button;
}
return (
<TooltipWithChildren message="This application is managed by an edge stack and can only be edited by an edge administrator">
<span>{button}</span>
</TooltipWithChildren>
);
}
@@ -0,0 +1,34 @@
import { PencilIcon } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
interface Props {
to?: string;
params?: Record<string, unknown>;
disabled?: boolean;
}
export function EditButton({
to = '',
params,
children,
disabled,
}: PropsWithChildren<Props>) {
return (
<Button
type="button"
color="light"
size="small"
data-cy="k8sAppDetail-editAppButton"
disabled={disabled}
as={disabled ? 'button' : Link}
props={disabled ? undefined : { to, params }}
icon={PencilIcon}
>
{children}
</Button>
);
}
@@ -2,7 +2,10 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import {
GitAuthModel,
GitCredentialsModel,
} from '@/react/portainer/gitops/types';
import { useCurrentUser } from '@/react/hooks/useUser';
import { UserId } from '@/portainer/users/types';
@@ -82,10 +85,10 @@ export function useSaveCredentialsIfRequired() {
}
}
export async function saveGitCredentialsIfNeeded<TGit extends GitAuthModel>(
export async function saveGitCredentialsIfNeeded(
userId: UserId,
gitModel: TGit
) {
gitModel: GitAuthModel
): Promise<GitCredentialsModel> {
let credentialsId = gitModel.RepositoryGitCredentialID;
let username = gitModel.RepositoryUsername;
let password = gitModel.RepositoryPassword;
@@ -112,9 +115,10 @@ export async function saveGitCredentialsIfNeeded<TGit extends GitAuthModel>(
}
return {
...gitModel,
RepositoryAuthentication: gitModel.RepositoryAuthentication,
RepositoryGitCredentialID: credentialsId,
RepositoryUsername: username,
RepositoryPassword: password,
RepositoryAuthorizationType: gitModel.RepositoryAuthorizationType,
};
}

Some files were not shown because too many files have changed in this diff Show More