Compare commits

...

47 Commits

Author SHA1 Message Date
portainer-bot[bot] aae5e533c6 chore(version): Bump 2.39.1 to 2.39.2 (#2585)
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
2026-05-07 02:51:53 +00: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
123 changed files with 4376 additions and 1453 deletions
+3
View File
@@ -114,6 +114,9 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off '@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error' '@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': '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': 'jsx-a11y/label-has-associated-control':
- error - error
- assert: either - assert: either
+9 -7
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { StorybookConfig } from '@storybook/react-webpack5'; import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack'; import { Configuration } from 'webpack';
import postcss from 'postcss'; import postcss from 'postcss';
@@ -85,12 +86,7 @@ const config: StorybookConfig = {
...config, ...config,
resolve: { resolve: {
...config.resolve, ...config.resolve,
plugins: [ tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
...(config.resolve?.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve?.extensions,
}),
],
}, },
module: { module: {
...config.module, ...config.module,
@@ -101,11 +97,17 @@ const config: StorybookConfig = {
staticDirs: ['./public'], staticDirs: ['./public'],
typescript: { typescript: {
reactDocgen: 'react-docgen-typescript', reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
}, },
framework: { framework: {
name: '@storybook/react-webpack5', name: '@storybook/react-webpack5',
options: {}, options: {},
}, },
docs: {},
}; };
export default config; export default config;
+8 -4
View File
@@ -11,7 +11,7 @@ see also:
## Package Manager ## Package Manager
- **PNPM** 10+ (for frontend) - **PNPM** 10+ (for frontend)
- **Go** 1.25.7 (for backend) - **Go** 1.25.8 (for backend)
## Build Commands ## Build Commands
@@ -27,9 +27,13 @@ make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999) make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server make dev-server # Run containerized Go server
pnpm run dev # Webpack dev server # Frontend
pnpm run build # Build frontend with webpack pnpm dev # Webpack dev server
pnpm run test # Run frontend tests 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 # Testing
make test # All tests (backend + frontend) make test # All tests (backend + frontend)
+5 -2
View File
@@ -4,7 +4,8 @@ WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2 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 # Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
test-client: ## Run client tests test-client: ## Run client tests
pnpm run test $(ARGS) --coverage pnpm run test $(ARGS) --coverage
TEST_PACKAGES?=./...
test-server: ## Run server tests 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 ##@ Dev
.PHONY: dev dev-client dev-server .PHONY: dev dev-client dev-server
+4 -4
View File
@@ -8,8 +8,8 @@ import (
"time" "time"
) )
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) { func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil) getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
if err != nil { if err != nil {
return return
} }
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
return return
} }
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) { func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken() tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
if err != nil { if err != nil {
return return
} }
+9 -2
View File
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String() flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).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",
"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() kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) { if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable() ex, err := os.Executable()
if err != nil { if err != nil {
+54
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
portainer "github.com/portainer/portainer/api"
zerolog "github.com/rs/zerolog/log" zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
require.True(t, *opts.EnableEdgeComputeFeatures) 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) { func TestParseTLSFlags(t *testing.T) {
testCases := []struct { testCases := []struct {
name string 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.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL) settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if flags.KubectlShellImageSet {
settings.KubectlShellImage = *flags.KubectlShellImage
}
if *flags.Labels != nil { if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels settings.BlackListedLabels = *flags.Labels
} }
+63 -1
View File
@@ -5,6 +5,9 @@ import (
"path" "path"
"testing" "testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -12,7 +15,7 @@ import (
const secretFileName = "secret.txt" const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string { 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) require.NoError(t, err)
return secretPath return secretPath
} }
@@ -38,6 +41,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
require.Len(t, encryptionKey, 32) 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) { func TestDBSecretPath(t *testing.T) {
tests := []struct { tests := []struct {
keyFilenameFlag string keyFilenameFlag string
+1
View File
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage, KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention, IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
} }
return store.SettingsService.UpdateSettings(defaultSettings) return store.SettingsService.UpdateSettings(defaultSettings)
@@ -613,7 +613,7 @@
"RequiredPasswordLength": 12 "RequiredPasswordLength": 12
}, },
"KubeconfigExpiry": "0", "KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.0", "KubectlShellImage": "portainer/kubectl-shell:2.39.2",
"LDAPSettings": { "LDAPSettings": {
"AnonymousMode": true, "AnonymousMode": true,
"AutoCreateUsers": true, "AutoCreateUsers": true,
@@ -942,7 +942,7 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.39.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}, },
"webhooks": null "webhooks": null
} }
+11 -15
View File
@@ -66,7 +66,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath, EnvFilePath: envFilePath,
Host: url, Host: url,
ProjectName: stack.Name, ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries), Registries: portainerRegistriesToAuthConfigs(options.Registries),
}, },
ForceRecreate: options.ForceRecreate, ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit, AbortOnContainerExit: options.AbortOnContainerExit,
@@ -97,7 +97,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath, EnvFilePath: envFilePath,
Host: url, Host: url,
ProjectName: stack.Name, ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries), Registries: portainerRegistriesToAuthConfigs(options.Registries),
}, },
Remove: options.Remove, Remove: options.Remove,
Args: options.Args, Args: options.Args,
@@ -146,7 +146,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath, EnvFilePath: envFilePath,
Host: url, Host: url,
ProjectName: stack.Name, ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries), Registries: portainerRegistriesToAuthConfigs(options.Registries),
}) })
return errors.Wrap(err, "failed to pull images of the stack") 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 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 var authConfigs []types.AuthConfig
for _, r := range registries { for _, r := range registries {
@@ -242,7 +247,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
if r.Authentication { if r.Authentication {
var err error var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r) ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil { if err != nil {
continue continue
} }
@@ -254,16 +259,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
return authConfigs return authConfigs
} }
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) { func getEffectiveRegUsernamePassword(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
}
username, password, err := registryutils.GetRegEffectiveCredential(registry) username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil { if err != nil {
log.Warn(). log.Warn().
+72
View File
@@ -6,6 +6,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
portainer "github.com/portainer/portainer/api" 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) 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 { for _, registry := range registries {
if registry.Authentication { if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry) username, password, err := getEffectiveRegUsernamePassword(&registry)
if err != nil { if err != nil {
continue continue
} }
+51 -20
View File
@@ -3,23 +3,42 @@ package git
import ( import (
"context" "context"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" 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"
"github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "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/filemode"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http" 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/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors" "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 { type gitClient struct {
preserveGitDirectory bool 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 { func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
gitOptions := git.CloneOptions{ gitOptions := &git.CloneOptions{
URL: opt.repositoryUrl, URL: opt.repositoryUrl,
Depth: opt.depth, Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify, 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) gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
} }
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions) return c.Download(ctx, dst, 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
} }
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) { func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
@@ -78,6 +106,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
if err.Error() == "authentication required" { if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure return "", gittypes.ErrAuthenticationFailure
} }
return "", errors.Wrap(err, "failed to list repository refs") 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" { if ref.Name().String() == "HEAD" {
continue continue
} }
ret = append(ret, ref.Name().String()) ret = append(ret, ref.Name().String())
} }
@@ -225,5 +255,6 @@ func checkGitError(err error) error {
} else if errMsg == "authentication required" { } else if errMsg == "authentication required" {
return gittypes.ErrAuthenticationFailure return gittypes.ErrAuthenticationFailure
} }
return err return err
} }
+113 -3
View File
@@ -3,13 +3,17 @@ package git
import ( import (
"context" "context"
"os" "os"
"path/filepath"
"testing" "testing"
"time"
"github.com/portainer/portainer/api/archive" "github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" 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"
"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/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -18,7 +22,7 @@ import (
func setup(t *testing.T) string { func setup(t *testing.T) string {
dir := t.TempDir() 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) file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
if err != nil { if err != nil {
@@ -53,7 +57,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
t.Logf("Cloning into %s", dir) t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
require.NoError(t, err) require.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git")) assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
} }
func Test_cloneRepository(t *testing.T) { func Test_cloneRepository(t *testing.T) {
@@ -146,6 +150,112 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
return count 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) { func Test_listRefsPrivateRepository(t *testing.T) {
ensureIntegrationTest(t) ensureIntegrationTest(t)
+1
View File
@@ -7,6 +7,7 @@ import (
var ( var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct") 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") 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 type GitCredentialAuthType int
@@ -2,8 +2,14 @@ package customtemplates
import ( import (
"net/http" "net/http"
"strconv"
portainer "github.com/portainer/portainer/api" 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" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "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) return httperror.BadRequest("Invalid custom template identifier route variable", err)
} }
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) var customTemplate *portainer.CustomTemplate
if handler.DataStore.IsErrObjectNotFound(err) { if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err) var err error
} else if err != nil { customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", 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)
}
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 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 var customTemplate *portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID)) 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) return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err) 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" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false) _, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds) 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, 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: 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: 2, Username: "std2", Role: portainer.StandardUserRole}))
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
return nil 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) { test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil) r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
@@ -1,6 +1,7 @@
package containers package containers
import ( import (
"context"
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" 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) 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 { if err != nil {
return httperror.InternalServerError("Error recreating container", err) return httperror.InternalServerError("Error recreating container", err)
} }
+7 -1
View File
@@ -19,6 +19,7 @@ type StackViewModel struct {
Name string Name string
IsExternal bool IsExternal bool
Type portainer.StackType Type portainer.StackType
Labels map[string]string
} }
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access. // 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, Name: name,
IsExternal: true, IsExternal: true,
Type: portainer.DockerComposeStack, Type: portainer.DockerComposeStack,
Labels: container.Labels,
} }
} }
} }
@@ -68,6 +70,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name, Name: name,
IsExternal: true, IsExternal: true,
Type: portainer.DockerSwarmStack, 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, return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
func(item StackViewModel) (*portainer.ResourceControl, error) { 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" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "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/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -28,12 +28,13 @@ func TestHandler_getDockerStacks(t *testing.T) {
containers := []types.Container{ containers := []types.Container{
{ {
Labels: map[string]string{ Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack1", consts.ComposeStackNameLabel: "stack1",
}, },
}, },
{ {
Labels: map[string]string{ 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{ Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{ Annotations: swarm.Annotations{
Labels: map[string]string{ 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(&stack1))
is.NoError(tx.Stack().Create(&portainer.Stack{ is.NoError(tx.Stack().Create(&portainer.Stack{
ID: 2, ID: 2,
Name: "stack2", Name: "stack2", // stack 2 on env 2
EndpointID: 2, EndpointID: 2,
Type: portainer.DockerSwarmStack, Type: portainer.DockerSwarmStack,
})) }))
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) 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 return nil
})) }))
// testing admin user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error { is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{ stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: true, IsAdmin: true,
@@ -93,11 +96,43 @@ func TestHandler_getDockerStacks(t *testing.T) {
Name: "stack2", Name: "stack2",
IsExternal: true, IsExternal: true,
Type: portainer.DockerComposeStack, Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
}, },
{ {
Name: "stack3", Name: "stack3",
IsExternal: true, IsExternal: true,
Type: portainer.DockerSwarmStack, 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 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 endpointIds query []int false "will return only these environments(endpoints)"
// @param excludeIds query []int false "will exclude 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 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 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)" // @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 edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID excludeIds []portainer.EndpointID
excludeGroupIds []portainer.EndpointGroupID
edgeGroupIds []portainer.EdgeGroupID edgeGroupIds []portainer.EdgeGroupID
excludeEdgeGroupIds []portainer.EdgeGroupID excludeEdgeGroupIds []portainer.EdgeGroupID
} }
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err return EnvironmentsQuery{}, err
} }
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
}
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds") edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
if err != nil { if err != nil {
return EnvironmentsQuery{}, err return EnvironmentsQuery{}, err
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
tagIds: tagIDs, tagIds: tagIDs,
endpointIds: endpointIDs, endpointIds: endpointIDs,
excludeIds: excludeIDs, excludeIds: excludeIDs,
excludeGroupIds: excludeGroupIDs,
tagsPartialMatch: tagsPartialMatch, tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs, groupIds: groupIDs,
status: status, 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 { if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds) filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
} }
+40
View File
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
runTests(tests, t, handler, environments) 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) { func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
n := 10000 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) bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/agent_versions", h.Handle("/endpoints/agent_versions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet) 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}", h.Handle("/endpoints/{id}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.39.0 // @version 2.39.2
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService
+1 -1
View File
@@ -113,7 +113,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).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.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet) namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
-1
View File
@@ -28,7 +28,6 @@ import (
// @produce json // @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" // @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 nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200 // @success 200
// @failure 400 // @failure 400
// @failure 403 // @failure 403
-1
View File
@@ -32,7 +32,6 @@ type execStartOperationPayload struct {
// @produce json // @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" // @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 nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200 // @success 200
// @failure 400 // @failure 400
// @failure 409 // @failure 409
-1
View File
@@ -31,7 +31,6 @@ import (
// @param podName query string true "name of the pod containing the container" // @param podName query string true "name of the pod containing the container"
// @param containerName query string true "name of the container" // @param containerName query string true "name of the container"
// @param command query string true "command to execute in 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 // @success 200
// @failure 400 // @failure 400
// @failure 403 // @failure 403
-1
View File
@@ -18,7 +18,6 @@ import (
// @accept json // @accept json
// @produce json // @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" // @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" // @success 200 "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 403 "Permission denied" // @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) { func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
type PartialContainer struct { type PartialContainer struct {
HostConfig struct { HostConfig struct {
Privileged bool `json:"Privileged"` Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"` PidMode string `json:"PidMode"`
Devices []any `json:"Devices"` Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"` Sysctls map[string]any `json:"Sysctls"`
CapAdd []string `json:"CapAdd"` SecurityOpt []string `json:"SecurityOpt"`
CapDrop []string `json:"CapDrop"` CapAdd []string `json:"CapAdd"`
Binds []string `json:"Binds"` CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Mounts []struct {
Type string `json:"Type"`
} `json:"Mounts"`
} `json:"HostConfig"` } `json:"HostConfig"`
} }
forbiddenResponse := &http.Response{ forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden, StatusCode: http.StatusForbidden,
Body: http.NoBody,
} }
tokenData, err := security.RetrieveTokenData(request) tokenData, err := security.RetrieveTokenData(request)
@@ -230,7 +235,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return nil, ErrContainerCapabilitiesForbidden 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 { for _, bind := range partialContainer.HostConfig.Binds {
if strings.HasPrefix(bind, "/") { if strings.HasPrefix(bind, "/") {
return forbiddenResponse, ErrBindMountsForbidden 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)) request.Body = io.NopCloser(bytes.NewBuffer(body))
} }
@@ -252,3 +265,45 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return response, err 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 ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io" "io"
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@@ -18,6 +18,70 @@ import (
const serviceObjectIdentifier = "ID" 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) { 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{}) service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
if err != nil { 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) { 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) isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -118,25 +168,45 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
partialService := &PartialService{} if isAdminOrEndpointAdmin {
if err := json.Unmarshal(body, partialService); err != nil { if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err return nil, err
} }
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) { if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts { return &http.Response{
if mount.Type == "bind" { StatusCode: http.StatusForbidden,
return forbiddenResponse, errors.New("forbidden to use bind mounts") 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/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs" "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/network"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
@@ -109,6 +110,28 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
"volumes": (*Transport).proxyVolumeRequest, "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 // ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation. // on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) { 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) return proxyFunc(transport, request, unversionedPath)
} }
if isAdminOnlyRoute(request.Method, unversionedPath) {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request) return transport.executeDockerRequest(request)
} }
@@ -261,6 +288,11 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
if action == "json" { if action == "json" {
return transport.rewriteOperation(request, transport.containerInspectOperation) return transport.rewriteOperation(request, transport.containerInspectOperation)
} }
if action == "update" {
return transport.decorateContainerUpdateOperation(request, containerID)
}
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match { } else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests // Handle /containers/{id} requests
@@ -292,6 +324,11 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
if match, _ := path.Match("/services/*/*", requestPath); match { if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests // Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath)) 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 { if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err return nil, err
@@ -108,6 +108,141 @@ func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Serve
return srv, version 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) { func TestTransport_getRealResourceID(t *testing.T) {
srv, _ := mockDockerAPIServer(t, RoutesDefinition{ srv, _ := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}}, {http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
+49
View File
@@ -1,9 +1,11 @@
package docker package docker
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"path" "path"
@@ -15,6 +17,7 @@ import (
"github.com/portainer/portainer/api/logs" "github.com/portainer/portainer/api/logs"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
) )
const volumeObjectIdentifier = "ResourceID" const volumeObjectIdentifier = "ResourceID"
@@ -122,12 +125,58 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels") 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) { func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request) tokenData, err := security.RetrieveTokenData(request)
if err != nil { if err != nil {
return nil, err 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") volumeID := request.Header.Get("X-Portainer-VolumeName")
if volumeID != "" { 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)
}
+2 -14
View File
@@ -447,26 +447,14 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
return tokenData, nil 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) { 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] tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 { if !ok || len(tokens) == 0 {
return "", false return "", false
} }
token = tokens[0] token := tokens[0]
token = strings.TrimPrefix(token, "Bearer ") token = strings.TrimPrefix(token, "Bearer ")
return token, true return token, true
+41 -6
View File
@@ -1,6 +1,8 @@
package registryutils package registryutils
import ( import (
"context"
"fmt"
"time" "time"
portainer "github.com/portainer/portainer/api" 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() 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) ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken() accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -24,12 +29,34 @@ func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) er
registry.AccessToken = *accessToken registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix() 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) return tx.Registry().Update(registry.ID, registry)
} }
func parseRegToken(registry *portainer.Registry) (username, password string, err error) { // ValidateRegistriesECRTokens refreshes and persists ECR tokens for all registries that need it.
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region). // Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention.
ParseAuthorizationToken(registry.AccessToken) 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 { func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
@@ -57,7 +84,15 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
password = registry.Password password = registry.Password
if registry.Type == portainer.EcrRegistry { 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 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 { type stubUserService struct {
dataservices.UserService 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. // parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob { 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 { if err != nil {
return models.K8sCronJob{} return models.K8sCronJob{}
} }
+62
View File
@@ -5,7 +5,10 @@ import (
"testing" "testing"
models "github.com/portainer/portainer/api/http/models/kubernetes" 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" batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake" kfake "k8s.io/client-go/kubernetes/fake"
) )
@@ -64,3 +67,62 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Logf("Deleted Cron Jobs") 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 ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true, TTY: true,
}, scheme.ParameterCodec) }, 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( exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config, config,
"GET", // WebSocket uses GET for the upgrade request "GET", // WebSocket uses GET for the upgrade request
req.URL().String(), req.URL().String(),
channelProtocolList..., channelProtocolList...,
) )
if err != nil { if err == nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL()) err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil { if err == nil {
errChan <- err
return return
} }
log.Warn().
Err(err).
Str("context", "StartExecProcess").
Msg("WebSocket exec failed, falling back to SPDY")
} }
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{ // Fall back to SPDY executor
Stdin: stdin, exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
Stdout: stdout, if err != nil {
Tty: true, errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
}) return
}
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil { if err != nil {
var exitError utilexec.ExitError var exitError utilexec.ExitError
if !errors.As(err, &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 // getCronJobExecutions returns the jobs for a given cronjob
// it returns the jobs for the 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 maxItems := 5
results := make([]models.K8sJob, 0) results := make([]models.K8sJob, 0)
for _, job := range jobs.Items { for _, job := range jobs.Items {
if job.Namespace != cronJobNamespace {
continue
}
for _, owner := range job.OwnerReferences { for _, owner := range job.OwnerReferences {
if owner.Kind == "CronJob" && owner.Name == cronJobName { if owner.Kind == "CronJob" && owner.Name == cronJobName {
results = append(results, kcl.parseJob(job)) results = append(results, kcl.parseJob(job))
-1
View File
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
name: portainer-ctx name: portainer-ctx
current-context: portainer-ctx current-context: portainer-ctx
kind: Config kind: Config
preferences: {}
users: users:
- name: test-user - name: test-user
user: user:
+5 -5
View File
@@ -73,7 +73,7 @@ func (Service) AuthenticateUser(username, password string, settings *portainer.L
if err != nil { if err != nil {
return err return err
} }
defer connection.Close() defer func() { _ = connection.Close() }()
if !settings.AnonymousMode { if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password) err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -108,7 +108,7 @@ func (Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer connection.Close() defer func() { _ = connection.Close() }()
if !settings.AnonymousMode { if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password) err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -133,7 +133,7 @@ func (Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer connection.Close() defer func() { _ = connection.Close() }()
if !settings.AnonymousMode { if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password) err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -182,7 +182,7 @@ func (Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPU
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer connection.Close() defer func() { _ = connection.Close() }()
if !settings.AnonymousMode { if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password) err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -309,7 +309,7 @@ func (Service) TestConnectivity(settings *portainer.LDAPSettings) error {
if err != nil { if err != nil {
return err return err
} }
defer connection.Close() defer func() { _ = connection.Close() }()
if !settings.AnonymousMode { if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password) 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) conn, err := createConnectionForURL(settings.URL, settings)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, conn) require.NotNil(t, conn)
conn.Close() require.NoError(t, conn.Close())
// TLS // TLS
@@ -45,7 +45,7 @@ func TestCreateConnectionForURL(t *testing.T) {
conn, err = createConnectionForURL(settings.URL, settings) conn, err = createConnectionForURL(settings.URL, settings)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, conn) require.NotNil(t, conn)
conn.Close() require.NoError(t, conn.Close())
// Invalid TLS // Invalid TLS
+3 -2
View File
@@ -141,6 +141,7 @@ type (
LogLevel *string LogLevel *string
LogMode *string LogMode *string
KubectlShellImage *string KubectlShellImage *string
KubectlShellImageSet bool
PullLimitCheckDisabled *bool PullLimitCheckDisabled *bool
TrustedOrigins *string TrustedOrigins *string
} }
@@ -576,7 +577,7 @@ type (
} }
PolicyChartBundle struct { PolicyChartBundle struct {
PolicyChartSummary PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"` EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"` Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"` PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
@@ -1874,7 +1875,7 @@ type (
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "2.39.0" APIVersion = "2.39.2"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support) // Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS" APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called // 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, ComposeOptions: options,
ForceRecreate: forceRecreate, 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 { 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" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -44,6 +45,10 @@ func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityC
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
return nil, err
}
config := &ComposeStackDeploymentConfig{ config := &ComposeStackDeploymentConfig{
stack: stack, stack: stack,
endpoint: endpoint, endpoint: endpoint,
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils" "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) filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
return nil, err
}
config := &SwarmStackDeploymentConfig{ config := &SwarmStackDeploymentConfig{
stack: stack, stack: stack,
endpoint: endpoint, 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 package stackutils
import ( import (
portainer "github.com/portainer/portainer/api" "context"
"path"
"github.com/docker/cli/cli/compose/loader" portainer "github.com/portainer/portainer/api"
"github.com/docker/cli/cli/compose/types" "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" "github.com/pkg/errors"
) )
func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error { type StackFileValidationConfig struct {
composeConfigYAML, err := loader.ParseYAML(stackFileContent) 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 { if err != nil {
return err return err
} }
composeConfigFile := types.ConfigFile{ for _, service := range composeConfig.Services {
Config: composeConfigYAML, if !config.SecuritySettings.AllowBindMountsForRegularUsers {
}
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 _, volume := range service.Volumes { for _, volume := range service.Volumes {
if volume.Type == "bind" { if volume.Type == "bind" {
return errors.New("bind-mount disabled for non administrator users") 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") 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") 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") 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") 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") 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 { 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) { for _, file := range GetStackFilePaths(stack, false) {
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file) stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to get stack file content") 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") return errors.Wrap(err, "stack config file is invalid")
} }
} }
+225 -20
View File
@@ -1,10 +1,11 @@
package stackutils package stackutils
import ( import (
"os"
"path/filepath"
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -30,32 +31,236 @@ networks:
`) `)
securitySettings := &portainer.EndpointSecuritySettings{} securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(yamlContent, securitySettings) err := IsValidStackFile(StackFileValidationConfig{
Content: yamlContent,
SecuritySettings: securitySettings,
})
require.NoError(t, err) require.NoError(t, err)
} }
func TestIsValidStackFile_PortEnv(t *testing.T) { // TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects
yamlContent := []byte(` // 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" version: "3"
services: services:
webservice: api:
image: nginx image: nginx
container_name: hello-world
networks:
- "mynet1"
ports: ports:
- "${PORT}:80" - "${API_PORT}:${API_PORT}"
`),
networks: SecuritySettings: securitySettings,
mynet1: Env: map[string]string{"API_PORT": "3000"},
driver: bridge })
ipam:
config:
- subnet: 172.16.0.0/24
`)
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(yamlContent, securitySettings)
require.NoError(t, err) 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 { func StackResourceControlID(endpointID portainer.EndpointID, name string) string {
return stackutils.ResourceControlID(endpointID, name) 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 },
})
}
@@ -13,7 +13,7 @@ export interface StandaloneFileContentPayload {
stackFileContent: string; stackFileContent: string;
/** List of environment variables */ /** List of environment variables */
env?: Array<Pair>; env?: Array<Pair> | null;
/** Whether the stack is from an app template */ /** Whether the stack is from an app template */
fromAppTemplate?: boolean; fromAppTemplate?: boolean;
@@ -13,7 +13,7 @@ export interface SwarmFileContentPayload {
stackFileContent: string; stackFileContent: string;
/** List of environment variables */ /** List of environment variables */
env?: Array<Pair>; env?: Array<Pair> | null;
/** Whether the stack is from an app template */ /** Whether the stack is from an app template */
fromAppTemplate?: boolean; fromAppTemplate?: boolean;
+2 -2
View File
@@ -54,7 +54,7 @@ export interface Stack {
EndpointId: number; EndpointId: number;
SwarmId: string; SwarmId: string;
EntryPoint: string; EntryPoint: string;
Env: EnvVar[]; Env: EnvVar[] | null;
ResourceControl?: ResourceControlResponse; ResourceControl?: ResourceControlResponse;
Status: StackStatus; Status: StackStatus;
ProjectPath: string; ProjectPath: string;
@@ -84,7 +84,7 @@ export type StackFile = {
}; };
export interface GitStackPayload { export interface GitStackPayload {
env: Array<EnvVar>; env: Array<EnvVar> | null;
prune?: boolean; prune?: boolean;
RepositoryReferenceName?: string; RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean; RepositoryAuthentication?: boolean;
@@ -66,7 +66,6 @@
.pagination > .active > span:focus, .pagination > .active > span:focus,
.pagination > .active > button:focus { .pagination > .active > button:focus {
@apply text-blue-7; @apply text-blue-7;
z-index: 3;
cursor: default; cursor: default;
/* background-color: var(--text-pagination-span-color); */ /* background-color: var(--text-pagination-span-color); */
background-color: var(--bg-pagination-color); background-color: var(--bg-pagination-color);
+2 -2
View File
@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={clsx( 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 className
)} )}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
@@ -76,7 +76,7 @@ const SheetOverlay = forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( 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: { variants: {
side: { side: {
@@ -15,11 +15,10 @@ export function StickyFooter({
<div <div
className={clsx( className={clsx(
styles.actionBar, 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-40 h-16',
'fixed bottom-0 right-0 z-10 h-16',
'flex items-center px-6', 'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]', '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 className
)} )}
> >
+12 -3
View File
@@ -1,12 +1,13 @@
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react'; import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
import { Button } from './Button';
import { LoadingButton } from './LoadingButton'; import { LoadingButton } from './LoadingButton';
import { Button } from './Button';
type ConfirmOrClick = type ConfirmOrClick =
| { | {
@@ -26,7 +27,10 @@ export function DeleteButton({
size, size,
children, children,
isLoading, isLoading,
text = 'Remove',
loadingText = 'Removing...', loadingText = 'Removing...',
icon = false,
type,
'data-cy': dataCy, 'data-cy': dataCy,
...props ...props
}: PropsWithChildren< }: PropsWithChildren<
@@ -35,7 +39,10 @@ export function DeleteButton({
size?: ComponentProps<typeof Button>['size']; size?: ComponentProps<typeof Button>['size'];
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
text?: string;
loadingText?: string; loadingText?: string;
icon?: boolean;
type?: ComponentProps<typeof Button>['type'];
} }
>) { >) {
if (isLoading === undefined) { if (isLoading === undefined) {
@@ -46,10 +53,11 @@ export function DeleteButton({
disabled={disabled || isLoading} disabled={disabled || isLoading}
onClick={() => handleClick()} onClick={() => handleClick()}
icon={Trash2} icon={Trash2}
className="!m-0" className={clsx('!m-0', icon ? 'btn-icon' : '')}
data-cy={dataCy} data-cy={dataCy}
type={type}
> >
{children || 'Remove'} {children || text}
</Button> </Button>
); );
} }
@@ -65,6 +73,7 @@ export function DeleteButton({
data-cy={dataCy} data-cy={dataCy}
isLoading={isLoading} isLoading={isLoading}
loadingText={loadingText} loadingText={loadingText}
type={type}
> >
{children || 'Remove'} {children || 'Remove'}
</LoadingButton> </LoadingButton>
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { difference } from 'lodash';
import { import {
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
@@ -14,6 +16,7 @@ import { defaultGetRowId } from './defaultGetRowId';
import { Table } from './Table'; import { Table } from './Table';
import { NestedTable } from './NestedTable'; import { NestedTable } from './NestedTable';
import { DatatableContent } from './DatatableContent'; import { DatatableContent } from './DatatableContent';
import { DatatableFooter } from './DatatableFooter';
import { BasicTableSettings, DefaultType } from './types'; import { BasicTableSettings, DefaultType } from './types';
interface Props<D extends DefaultType> extends AutomationTestingProps { interface Props<D extends DefaultType> extends AutomationTestingProps {
@@ -69,6 +72,18 @@ export function NestedDatatable<D extends DefaultType>({
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }), ...(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 ( return (
<NestedTable> <NestedTable>
<Table.Container noWidget> <Table.Container noWidget>
@@ -80,6 +95,17 @@ export function NestedDatatable<D extends DefaultType>({
aria-label={ariaLabel} aria-label={ariaLabel}
data-cy={dataCy} 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> </Table.Container>
</NestedTable> </NestedTable>
); );
+21 -9
View File
@@ -7,31 +7,43 @@ interface Props<D extends DefaultType = DefaultType> {
cells: Cell<D, unknown>[]; cells: Cell<D, unknown>[];
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
'aria-selected'?: boolean;
} }
export function TableRow<D extends DefaultType = DefaultType>({ export function TableRow<D extends DefaultType = DefaultType>({
cells, cells,
className, className,
onClick, onClick,
'aria-selected': ariaSelected,
}: Props<D>) { }: Props<D>) {
return ( return (
<tr <tr
className={clsx(className, { 'cursor-pointer': !!onClick })} className={clsx(className, { 'cursor-pointer': !!onClick })}
onClick={onClick} onClick={onClick}
aria-selected={ariaSelected}
> >
{cells.map((cell) => ( {cells.map((cell) => {
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}> const { className, width } = parseMeta(cell.column.columnDef.meta);
{flexRender(cell.column.columnDef.cell, cell.getContext())} return (
</td> <td key={cell.id} className={className} style={{ width }}>
))} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr> </tr>
); );
} }
function getClassName<D extends DefaultType = DefaultType>( function parseMeta<D extends DefaultType = DefaultType>(
meta: ColumnMeta<D, unknown> | undefined meta: ColumnMeta<D, unknown> | undefined
) { ) {
return !!meta && 'className' in meta && typeof meta.className === 'string' const className =
? meta.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, enableHiding: false,
meta: { meta: {
width: 50, width: '50px',
}, },
}; };
} }
@@ -52,7 +52,7 @@ export function FormSection({
</FormSectionTitle> </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 */} {/* 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 */} {/* 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> </div>
); );
} }
+4 -4
View File
@@ -37,11 +37,11 @@ export function Modal({
<Context.Provider value> <Context.Provider value>
<DialogOverlay <DialogOverlay
isOpen isOpen
className={clsx( className={clsx(styles.overlay, 'flex items-center justify-center')}
styles.overlay,
'flex items-center justify-center z-50'
)}
onDismiss={onDismiss} 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 <DialogContent
aria-label={ariaLabel} aria-label={ariaLabel}
@@ -6,14 +6,14 @@
.background-error { .background-error {
padding-top: 55px; padding-top: 55px;
background-image: url(~assets/images/icon-error.svg); background-image: url(~@/assets/images/icon-error.svg);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: top left; background-position: top left;
} }
.background-warning { .background-warning {
padding-top: 55px; padding-top: 55px;
background-image: url(~assets/images/icon-warning.svg); background-image: url(~@/assets/images/icon-warning.svg);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: top left; background-position: top left;
} }
@@ -44,7 +44,7 @@ export function HealthStatus({ health }: Props) {
<div className="vertical-center">{health.FailingStreak}</div> <div className="vertical-center">{health.FailingStreak}</div>
</DetailsTable.Row> </DetailsTable.Row>
{!!health.Log && ( {!!health.Log?.length && (
<DetailsTable.Row label="Last output"> <DetailsTable.Row label="Last output">
{health.Log[health.Log.length - 1].Output} {health.Log[health.Log.length - 1].Output}
</DetailsTable.Row> </DetailsTable.Row>
@@ -5,7 +5,7 @@ import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
import { ResourceControlType } from '@/react/portainer/access-control/types'; import { ResourceControlType } from '@/react/portainer/access-control/types';
import { trimContainerName } from '@/docker/filters/utils'; import { trimContainerName } from '@/docker/filters/utils';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; 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 { Registry } from '@/react/portainer/registries/types/registry';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
@@ -32,7 +32,7 @@ export function ItemView() {
{ select: (c) => new ContainerDetailsViewModel(c) } { select: (c) => new ContainerDetailsViewModel(c) }
); );
const registriesQuery = useRegistries(); const registriesQuery = useEnvironmentRegistries(environmentId);
if ( if (
containerQuery.isLoading || containerQuery.isLoading ||
@@ -25,6 +25,7 @@ export function TasksDatatable({
search={search} search={search}
aria-label="Tasks table" aria-label="Tasks table"
data-cy="docker-service-tasks-nested-datatable" data-cy="docker-service-tasks-nested-datatable"
initialSortBy={{ id: 'Updated', desc: true }}
/> />
); );
} }
@@ -57,7 +57,7 @@ export function StackEditorTab({
); );
const initialValues: StackEditorFormValues = { const initialValues: StackEditorFormValues = {
environmentVariables: stack.Env, environmentVariables: stack.Env || [],
prune: !!(stack.Option && stack.Option.Prune), prune: !!(stack.Option && stack.Option.Prune),
stackFileContent: originalFileContent, stackFileContent: originalFileContent,
enabledWebhook: !!stack.Webhook, enabledWebhook: !!stack.Webhook,
@@ -117,6 +117,7 @@ export function StackEditorTab({
envType={envType} envType={envType}
schema={schemaQuery.data} schema={schemaQuery.data}
versions={versions} versions={versions}
isSubmitting={mutation.isLoading}
isSaved={mutation.isSuccess} isSaved={mutation.isSuccess}
webhookId={webhookId} webhookId={webhookId}
/> />
@@ -43,6 +43,7 @@ const defaultProps = {
schema: { type: 'object' } as JSONSchema7, schema: { type: 'object' } as JSONSchema7,
isOrphaned: false, isOrphaned: false,
stackId: 1, stackId: 1,
isSubmitting: false,
isSaved: false, isSaved: false,
webhookId: '', webhookId: '',
}; };
@@ -377,17 +378,7 @@ describe('form submission', () => {
}); });
it('should show loading text during submission', async () => { it('should show loading text during submission', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(() => {})); // Never resolves renderComponent({ isSubmitting: true }, {});
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);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/Deployment in progress.../)).toBeInTheDocument(); expect(screen.getByText(/Deployment in progress.../)).toBeInTheDocument();
@@ -29,6 +29,7 @@ interface StackEditorTabInnerProps {
versions?: Array<number>; versions?: Array<number>;
stackId: Stack['Id']; stackId: Stack['Id'];
isSaved: boolean; isSaved: boolean;
isSubmitting: boolean;
webhookId: string; webhookId: string;
} }
@@ -42,20 +43,15 @@ export function StackEditorTabInner({
versions, versions,
stackId, stackId,
isSaved, isSaved,
isSubmitting,
webhookId, webhookId,
}: StackEditorTabInnerProps) { }: StackEditorTabInnerProps) {
const { authorized: isAuthorizedToUpdate } = useAuthorizations( const { authorized: isAuthorizedToUpdate } = useAuthorizations(
'PortainerStackUpdate' 'PortainerStackUpdate'
); );
const { const { values, errors, setFieldValue, isValid, initialValues } =
values, useFormikContext<StackEditorFormValues>();
errors,
setFieldValue,
isSubmitting,
isValid,
initialValues,
} = useFormikContext<StackEditorFormValues>();
usePreventExit( usePreventExit(
initialValues.stackFileContent, 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[] = [ const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 1 } as 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); const result = getEnvironmentOptions(groups, environments);
expect(result.length).toBe(1);
expect(result[0].label).toBe('Unassigned'); expect(result[0].label).toBe('Others');
expect(result[0].options[0]).toEqual({ label: 'Env 1', value: 1 }); expect(result[0].options).toEqual([
}); { label: 'Env 1', value: 1 },
{ label: 'Env 2', value: 2 },
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'
);
}); });
}); });
@@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { sortBy } from 'lodash';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
@@ -73,7 +74,11 @@ export function getEnvironmentOptions(
return acc; return acc;
} }
const groupId = environment.GroupId; let groupId = environment.GroupId;
if (!groups.some((g) => g.Id === groupId)) {
groupId = -1;
}
if (!acc[groupId]) { if (!acc[groupId]) {
acc[groupId] = []; acc[groupId] = [];
} }
@@ -87,13 +92,10 @@ export function getEnvironmentOptions(
return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => { return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => {
const parsedGroupId = parseInt(groupId, 10); const parsedGroupId = parseInt(groupId, 10);
const group = groups.find((g) => g.Id === parsedGroupId); const group = groups.find((g) => g.Id === parsedGroupId);
if (!group && parsedGroupId !== 1) {
throw new Error(`Missing group with id ${groupId}`);
}
return { return {
label: group?.Name || 'Unassigned', label: group?.Name || 'Others',
options: envOptions, options: sortBy(envOptions, 'label'),
}; };
}); });
} }
@@ -25,7 +25,7 @@ export async function duplicateStack({
fileContent: string; fileContent: string;
targetEnvironmentId: EnvironmentId; targetEnvironmentId: EnvironmentId;
type: StackType; type: StackType;
env?: Array<Pair>; env?: Array<Pair> | null;
}) { }) {
if (type === StackType.DockerSwarm) { if (type === StackType.DockerSwarm) {
const swarm = await getSwarm(targetEnvironmentId); const swarm = await getSwarm(targetEnvironmentId);
@@ -46,7 +46,7 @@ export function StackRedeployGitForm({ stack }: { stack: Stack }) {
SaveCredential: false, SaveCredential: false,
}, },
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate), autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
env: stack.Env, env: stack.Env || [],
prune: stack.Option?.Prune || false, prune: stack.Option?.Prune || false,
refName: stack.GitConfig?.ReferenceName || '', refName: stack.GitConfig?.ReferenceName || '',
tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false, tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false,
+1 -1
View File
@@ -15,7 +15,7 @@ export function useUpdateStackMutation() {
type Payload = { type Payload = {
stackFileContent: string; stackFileContent: string;
env?: EnvVarValues; env?: EnvVarValues | null;
prune?: boolean; prune?: boolean;
webhook?: string; webhook?: string;
repullImageAndRedeploy?: boolean; repullImageAndRedeploy?: boolean;
@@ -120,7 +120,7 @@ async function createStackAndGitCredential(
autoUpdate: AutoUpdateResponse | null; autoUpdate: AutoUpdateResponse | null;
} }
) { ) {
const newGitModel = await saveGitCredentialsIfNeeded(userId, payload.git); const resolvedAuth = await saveGitCredentialsIfNeeded(userId, payload.git);
return createStackFromGit({ return createStackFromGit({
deploymentType: payload.deploymentType, deploymentType: payload.deploymentType,
@@ -132,13 +132,13 @@ async function createStackAndGitCredential(
retryDeploy: payload.retryDeploy, retryDeploy: payload.retryDeploy,
staggerConfig: payload.staggerConfig, staggerConfig: payload.staggerConfig,
useManifestNamespaces: payload.useManifestNamespaces, useManifestNamespaces: payload.useManifestNamespaces,
repositoryUrl: newGitModel.RepositoryURL, repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: newGitModel.RepositoryReferenceName, repositoryReferenceName: payload.git.RepositoryReferenceName,
filePathInRepository: newGitModel.ComposeFilePathInRepository, filePathInRepository: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: newGitModel.RepositoryAuthentication, repositoryAuthentication: resolvedAuth.RepositoryAuthentication,
repositoryUsername: newGitModel.RepositoryUsername, repositoryUsername: resolvedAuth.RepositoryUsername,
repositoryPassword: newGitModel.RepositoryPassword, repositoryPassword: resolvedAuth.RepositoryPassword,
repositoryGitCredentialId: newGitModel.RepositoryGitCredentialID, repositoryGitCredentialId: resolvedAuth.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath, filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
perDeviceConfigsGroupMatchType: perDeviceConfigsGroupMatchType:
@@ -146,7 +146,7 @@ async function createStackAndGitCredential(
perDeviceConfigsMatchType: perDeviceConfigsMatchType:
payload.relativePathSettings?.PerDeviceConfigsMatchType, payload.relativePathSettings?.PerDeviceConfigsMatchType,
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath, perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
tlsSkipVerify: newGitModel.TLSSkipVerify, tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.autoUpdate, autoUpdate: payload.autoUpdate,
}); });
} }
@@ -1,14 +1,11 @@
import { Pencil } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react'; import { useCurrentStateAndParams } from '@uirouter/react';
import { Pod } from 'kubernetes-types/core/v1'; 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 { useNamespaceQuery } from '@/react/kubernetes/namespaces/queries/useNamespaceQuery';
import { Widget, WidgetBody } from '@@/Widget'; import { Widget, WidgetBody } from '@@/Widget';
import { AddButton, Button } from '@@/buttons'; import { AddButton } from '@@/buttons';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
import { applicationIsKind, isExternalApplication } from '../../utils'; import { applicationIsKind, isExternalApplication } from '../../utils';
import { appStackIdLabel, appStackKindLabel } from '../../constants'; import { appStackIdLabel, appStackKindLabel } from '../../constants';
@@ -17,6 +14,8 @@ import { useApplicationServices } from '../../queries/useApplicationServices';
import { useAppStackFile } from '../../queries/useAppStackFile'; import { useAppStackFile } from '../../queries/useAppStackFile';
import { Application } from '../../types'; import { Application } from '../../types';
import { EdgeEditButton } from './EdgeEditButton';
import { EditButton } from './EditButton';
import { RestartApplicationButton } from './RestartApplicationButton'; import { RestartApplicationButton } from './RestartApplicationButton';
import { RedeployApplicationButton } from './RedeployApplicationButton'; import { RedeployApplicationButton } from './RedeployApplicationButton';
import { RollbackApplicationButton } from './RollbackApplicationButton'; import { RollbackApplicationButton } from './RollbackApplicationButton';
@@ -42,9 +41,6 @@ export function ApplicationDetailsWidget() {
const namespaceData = useNamespaceQuery(environmentId, namespace); const namespaceData = useNamespaceQuery(environmentId, namespace);
const isSystemNamespace = namespaceData.data?.IsSystem; const isSystemNamespace = namespaceData.data?.IsSystem;
// check if user is edge admin
const edgeAdminQuery = useIsEdgeAdmin();
// get app info // get app info
const { data: app } = useApplication( const { data: app } = useApplication(
environmentId, environmentId,
@@ -81,35 +77,15 @@ export function ApplicationDetailsWidget() {
{!isSystemNamespace && ( {!isSystemNamespace && (
<div className="mb-4 flex flex-wrap gap-2"> <div className="mb-4 flex flex-wrap gap-2">
<Authorized authorizations="K8sApplicationDetailsW"> <Authorized authorizations="K8sApplicationDetailsW">
<Link {appStackKind === 'edge' ? (
to={ <EdgeEditButton stackId={appStackId} />
appStackKind === 'edge' ) : (
? 'edge.stacks.edit' <EditButton to=".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" />
{externalApp {externalApp
? 'Edit external application' ? 'Edit external application'
: 'Edit this application'} : 'Edit this application'}
</Button> </EditButton>
</Link> )}
</Authorized> </Authorized>
{!applicationIsKind<Pod>('Pod', app) && ( {!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 axios, { parseAxiosError } from '@/portainer/services/axios';
import { notifyError, notifySuccess } from '@/portainer/services/notifications'; 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 { useCurrentUser } from '@/react/hooks/useUser';
import { UserId } from '@/portainer/users/types'; 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, userId: UserId,
gitModel: TGit gitModel: GitAuthModel
) { ): Promise<GitCredentialsModel> {
let credentialsId = gitModel.RepositoryGitCredentialID; let credentialsId = gitModel.RepositoryGitCredentialID;
let username = gitModel.RepositoryUsername; let username = gitModel.RepositoryUsername;
let password = gitModel.RepositoryPassword; let password = gitModel.RepositoryPassword;
@@ -112,9 +115,10 @@ export async function saveGitCredentialsIfNeeded<TGit extends GitAuthModel>(
} }
return { return {
...gitModel, RepositoryAuthentication: gitModel.RepositoryAuthentication,
RepositoryGitCredentialID: credentialsId, RepositoryGitCredentialID: credentialsId,
RepositoryUsername: username, RepositoryUsername: username,
RepositoryPassword: password, RepositoryPassword: password,
RepositoryAuthorizationType: gitModel.RepositoryAuthorizationType,
}; };
} }
@@ -30,7 +30,7 @@ export function CreateGroupView() {
]} ]}
/> />
<div className="row"> <div className="row pb-20">
<div className="col-sm-12"> <div className="col-sm-12">
<Widget> <Widget>
<Widget.Body> <Widget.Body>
@@ -298,14 +298,13 @@ describe('EditGroupView', () => {
await user.click(submitButton); await user.click(submitButton);
// Verify the request URL and body // Verify the request URL and body.
await waitFor(() => { await waitFor(() => {
expect(requestUrl).toBe('/api/endpoint_groups/2'); expect(requestUrl).toBe('/api/endpoint_groups/2');
expect(requestBody).toEqual({ expect(requestBody).toEqual({
Name: 'Updated Group', Name: 'Updated Group',
Description: 'Test description', Description: 'Test description',
TagIDs: [1], TagIDs: [1],
AssociatedEndpoints: [1], // The associated environment ID
}); });
}); });
}); });
@@ -451,22 +450,9 @@ describe('EditGroupView', () => {
expect(elements[0]).toBeVisible(); expect(elements[0]).toBeVisible();
}); });
it('should include associated environment IDs in update payload', async () => { it('should NOT include AssociatedEndpoints in update payload (backend preserves associations)', async () => {
let requestBody: DefaultBodyType; let requestBody: DefaultBodyType;
const associatedEnvs = [
{
...mockEnvironment,
Id: 100,
Name: 'Env 100',
} as Partial<Environment>,
{
...mockEnvironment,
Id: 200,
Name: 'Env 200',
} as Partial<Environment>,
];
server.use( server.use(
http.put('/api/endpoint_groups/:id', async ({ request }) => { http.put('/api/endpoint_groups/:id', async ({ request }) => {
requestBody = await request.json(); requestBody = await request.json();
@@ -475,9 +461,7 @@ describe('EditGroupView', () => {
); );
const user = userEvent.setup(); const user = userEvent.setup();
renderEditGroupView({ renderEditGroupView();
associatedEnvironments: associatedEnvs,
});
// Wait for form to populate // Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i); const nameInput = await screen.findByLabelText(/Name/i);
@@ -495,11 +479,9 @@ describe('EditGroupView', () => {
await user.click(submitButton); await user.click(submitButton);
// Verify the associated environments are included in payload // Verify AssociatedEndpoints is absent — backend nil-check preserves existing memberships
await waitFor(() => { await waitFor(() => {
expect(requestBody).toMatchObject({ expect(requestBody).not.toHaveProperty('AssociatedEndpoints');
AssociatedEndpoints: [100, 200],
});
}); });
}); });
}); });
@@ -2,8 +2,6 @@ import { useRouter } from '@uirouter/react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FormikHelpers } from 'formik'; import { FormikHelpers } from 'formik';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import { useIdParam } from '@/react/hooks/useIdParam'; import { useIdParam } from '@/react/hooks/useIdParam';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
@@ -13,32 +11,22 @@ import { Alert } from '@@/Alert';
import { useGroup } from '../queries/useGroup'; import { useGroup } from '../queries/useGroup';
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation'; import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
import { GroupForm, GroupFormValues } from '../components/GroupForm'; import { GroupForm, GroupFormValues } from '../components/GroupForm';
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
export function EditGroupView() { export function EditGroupView() {
const groupId = useIdParam(); const groupId = useIdParam();
const router = useRouter(); const router = useRouter();
const groupQuery = useGroup(groupId); const groupQuery = useGroup(groupId);
const updateMutation = useUpdateGroupMutation();
// Fetch associated environments for this group (not for unassigned group)
const isUnassignedGroup = groupId === 1; const isUnassignedGroup = groupId === 1;
const environmentsQuery = useEnvironmentList( const updateMutation = useUpdateGroupMutation();
{ groupIds: [groupId], pageLimit: 0 },
{ enabled: !!groupId && !isUnassignedGroup }
);
const isLoading =
groupQuery.isLoading || (!isUnassignedGroup && environmentsQuery.isLoading);
const initialValues: GroupFormValues = useMemo( const initialValues: GroupFormValues = useMemo(
() => ({ () => ({
name: groupQuery.data?.Name ?? '', name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description ?? '', description: groupQuery.data?.Description ?? '',
tagIds: groupQuery.data?.TagIds ?? [], tagIds: groupQuery.data?.TagIds ?? [],
associatedEnvironments:
environmentsQuery.environments?.map((e) => e.Id) ?? [],
}), }),
[groupQuery.data, environmentsQuery.environments] [groupQuery.data]
); );
return ( return (
@@ -54,7 +42,7 @@ export function EditGroupView() {
<div className="row"> <div className="row">
<div className="col-sm-12"> <div className="col-sm-12">
<Widget> <Widget>
<Widget.Body loading={isLoading}> <Widget.Body loading={groupQuery.isLoading}>
{groupQuery.isError && ( {groupQuery.isError && (
<Alert color="error" title="Error"> <Alert color="error" title="Error">
Failed to load group details Failed to load group details
@@ -73,6 +61,15 @@ export function EditGroupView() {
</Widget> </Widget>
</div> </div>
</div> </div>
<div className="row pb-20">
<div className="col-sm-12">
<AssociatedEnvironmentsSelector
groupId={groupId}
readOnly={isUnassignedGroup}
/>
</div>
</div>
</> </>
); );
@@ -86,12 +83,11 @@ export function EditGroupView() {
name: values.name, name: values.name,
description: values.description, description: values.description,
tagIds: values.tagIds, tagIds: values.tagIds,
associatedEnvironments: values.associatedEnvironments, // associatedEnvironments omitted — backend preserves existing when field is absent (nil)
}, },
{ {
onSuccess() { onSuccess() {
resetForm(); resetForm();
notifySuccess('Success', 'Group successfully updated');
router.stateService.go('portainer.groups'); router.stateService.go('portainer.groups');
}, },
} }
@@ -0,0 +1,181 @@
import clsx from 'clsx';
import { useState } from 'react';
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import {
EnvironmentId,
EnvironmentGroupId,
} from '@/react/portainer/environments/types';
import { Datatable } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
import { TableRow } from '@@/datatables/TableRow';
import { Sheet, SheetContent, SheetClose, SheetHeader } from '@@/Sheet';
import { Button, LoadingButton } from '@@/buttons';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const columns = [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => (
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
),
}),
];
interface Props {
open: boolean;
onClose(): void;
/** IDs already in the group — excluded from the available list. Use for create-form contexts where no group ID exists yet. */
excludeIds?: EnvironmentId[];
/** Endpoint group IDs whose members are excluded from the available list. Prefer this over excludeIds when a group ID is available, to avoid sending thousands of individual IDs in the URL. */
excludeGroupIds?: EnvironmentGroupId[];
/** Called with the full env objects so callers can display names or extract IDs.
* Returns true if the add was committed, false if the user cancelled. */
onAdd:
| ((envs: EnvironmentTableData[]) => Promise<boolean>)
| ((envs: EnvironmentTableData[]) => void);
/** Loading state from the parent — disables buttons and shows spinner */
isLoading?: boolean;
}
export function AddEnvironmentsDrawer({
open,
onClose,
excludeIds,
excludeGroupIds,
onAdd,
isLoading,
}: Props) {
const tableState = useTableStateWithoutStorage('Name');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedEnvs, setSelectedEnvs] = useState<EnvironmentTableData[]>([]);
const [page, setPage] = useState(0);
const {
environments,
totalCount,
isLoading: isEnvsLoading,
} = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
excludeGroupIds,
});
function handleSelectionChange(ids: string[]) {
setSelectedIds(ids);
const currentDataMap = new Map(
(environments ?? []).map((env) => [String(env.Id), env])
);
setSelectedEnvs((prev) => {
const prevMap = new Map(prev.map((e) => [String(e.Id), e]));
// Keep already-tracked envs that remain selected
const kept = prev.filter((e) => ids.includes(String(e.Id)));
// Add newly selected envs from the current page
const added = ids
.filter((id) => !prevMap.has(id) && currentDataMap.has(id))
.map((id) => currentDataMap.get(id)!);
return [...kept, ...added];
});
}
async function handleAdd() {
const committed = await onAdd(selectedEnvs);
// Close only if the add was committed or there was no confirmation needed
if (committed || committed === undefined) {
resetSelection();
onClose();
}
}
function resetSelection() {
setSelectedIds([]);
setSelectedEnvs([]);
}
return (
<Sheet
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetSelection();
onClose();
}
}}
>
<SheetContent className="flex flex-col !p-0">
<div className="flex-1 p-4 overflow-auto">
<SheetHeader title="Add environments" />
<Datatable<EnvironmentTableData>
title="Available environments"
columns={columns}
dataset={environments ?? []}
settingsManager={tableState}
isLoading={isEnvsLoading}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)}
extendTableOptions={withControlledSelected(
handleSelectionChange,
selectedIds
)}
data-cy="add-environments-drawer-table"
/>
</div>
{/* Don't use StickyFooter here. StickyFooter has classes for the menu to the left that we don't want here */}
<div
className={clsx(
'bottom-0 left-0 right-0 w-full z-50 h-16 sticky justify-end gap-4',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]'
)}
>
<SheetClose asChild>
<Button
color="default"
disabled={isLoading}
data-cy="add-environments-cancel-button"
size="medium"
>
Cancel
</Button>
</SheetClose>
<LoadingButton
onClick={handleAdd}
disabled={selectedIds.length === 0 || isLoading}
isLoading={!!isLoading}
loadingText="Adding..."
data-cy="add-environments-confirm-button"
size="medium"
>
Confirm
</LoadingButton>
</div>
</SheetContent>
</Sheet>
);
}
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { vi } from 'vitest'; import { vi } from 'vitest';
@@ -11,227 +11,204 @@ import {
EnvironmentId, EnvironmentId,
} from '@/react/portainer/environments/types'; } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '../../types';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector'; import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
function createEnv(id: EnvironmentId, name: string): Environment { vi.mock('@@/modals/confirm', () => ({
return createMockEnvironment({ Id: id, Name: name, GroupId: 1 }); openConfirm: vi.fn().mockResolvedValue(true),
confirmDelete: vi.fn().mockResolvedValue(true),
}));
const mockGroup: EnvironmentGroup = {
Id: 2,
Name: 'Test Group',
Description: '',
TagIds: [],
};
function createEnv(id: EnvironmentId, name: string): Partial<Environment> {
return createMockEnvironment({ Id: id, Name: name });
} }
function setupMockServer(environments: Array<Environment> = []) { function setupMockServer({
associatedEnvs = [] as Array<Partial<Environment>>,
availableEnvs = [] as Array<Partial<Environment>>,
onPut = undefined as ((body: unknown) => void) | undefined,
} = {}) {
server.use( server.use(
http.get('/api/endpoints', () => http.get('/api/endpoint_groups/2', () => HttpResponse.json(mockGroup)),
HttpResponse.json(environments, { http.get('/api/endpoints', ({ request }) => {
headers: { const url = new URL(request.url);
'x-total-count': String(environments.length), const groupIds = [
'x-total-available': String(environments.length), ...url.searchParams.getAll('groupIds'),
}, ...url.searchParams.getAll('groupIds[]'),
}) ];
)
function makeResponse(envs: Array<Partial<Environment>>) {
return HttpResponse.json(envs, {
headers: {
'x-total-count': String(envs.length),
'x-total-available': String(envs.length),
},
});
}
if (groupIds.includes('2')) return makeResponse(associatedEnvs);
if (groupIds.includes('1')) return makeResponse(availableEnvs);
return makeResponse([]);
}),
http.put('/api/endpoint_groups/2', async ({ request }) => {
const body = await request.json();
onPut?.(body);
return HttpResponse.json(mockGroup);
})
); );
} }
function renderComponent({ function renderComponent(groupId = 2) {
associatedEnvironmentIds = [] as Array<EnvironmentId>,
initialAssociatedEnvironmentIds = [] as Array<EnvironmentId>,
onChange = vi.fn(),
}: {
associatedEnvironmentIds?: Array<EnvironmentId>;
initialAssociatedEnvironmentIds?: Array<EnvironmentId>;
onChange?: (ids: Array<EnvironmentId>) => void;
} = {}) {
const Wrapped = withTestQueryProvider(() => ( const Wrapped = withTestQueryProvider(() => (
<AssociatedEnvironmentsSelector <AssociatedEnvironmentsSelector groupId={groupId} readOnly={false} />
associatedEnvironmentIds={associatedEnvironmentIds}
initialAssociatedEnvironmentIds={initialAssociatedEnvironmentIds}
onChange={onChange}
/>
)); ));
return { return render(<Wrapped />);
...render(<Wrapped />),
onChange,
};
} }
describe('AssociatedEnvironmentsSelector', () => { describe('AssociatedEnvironmentsSelector', () => {
describe('Rendering', () => { describe('Rendering', () => {
it('should render both Available and Associated environments tables', async () => { it('renders the associated environments table', async () => {
setupMockServer(); setupMockServer();
renderComponent(); renderComponent();
expect( expect(
screen.getByRole('heading', { name: 'Available environments' }) await screen.findByRole('heading', { name: 'Associated environments' })
).toBeVisible();
expect(
screen.getByRole('heading', { name: 'Associated environments' })
).toBeVisible(); ).toBeVisible();
}); });
it('should render instruction text', async () => { it('renders an Add button', async () => {
setupMockServer(); setupMockServer();
renderComponent(); renderComponent();
expect( expect(
await screen.findByText(/click on any environment entry to move it/i) await screen.findByTestId('add-environments-button')
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('should render Associated environments table with data-cy attribute', async () => { it('renders a Remove button that is initially disabled', async () => {
setupMockServer(); setupMockServer({ associatedEnvs: [createEnv(10, 'my-env')] });
renderComponent(); renderComponent();
await waitFor(() => { await screen.findByText('my-env');
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
}); const removeBtn = screen.getByTestId('remove-environments-button');
expect(removeBtn).toBeDisabled();
}); });
it('should display initially associated environments in Associated table', async () => { it('displays environments returned by the API', async () => {
const envs = [createEnv(10, 'associated-env-1')]; setupMockServer({ associatedEnvs: [createEnv(10, 'env-alpha')] });
renderComponent();
setupMockServer(envs); expect(await screen.findByText('env-alpha')).toBeInTheDocument();
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
});
await waitFor(() => {
expect(screen.getByText('associated-env-1')).toBeInTheDocument();
});
});
});
describe('Adding environments', () => {
it('should call onChange with new environment ID when clicking an available environment', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env')];
setupMockServer(envs);
renderComponent({ onChange });
const envRow = await screen.findByText('available-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([1]);
});
it('should append new environment to existing associated IDs', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env'), createEnv(10, 'existing')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
onChange,
});
// Wait for the available table to be ready and find the row
const availableTable = await screen.findByTestId(
'group-availableEndpoints'
);
await within(availableTable).findByText('available-env');
// Find the row element that contains the text and click it
const rows = within(availableTable).getAllByRole('row');
const envRow = rows.find(
(row) => row.textContent?.includes('available-env')
);
expect(envRow).toBeDefined();
await user.click(envRow!);
// Wait for onChange to be called with the new environment ID appended
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([10, 1]);
});
}); });
}); });
describe('Removing environments', () => { describe('Removing environments', () => {
it('should call onChange without the removed environment ID', async () => { it('enables Remove button when a row is selected', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); setupMockServer({ associatedEnvs: [createEnv(10, 'env-to-remove')] });
renderComponent();
const envs = [ await screen.findByText('env-to-remove');
createEnv(10, 'associated-env-1'),
createEnv(11, 'associated-env-2'),
];
setupMockServer(envs);
renderComponent({ // First checkbox is the select-all header, second is the first row
associatedEnvironmentIds: [10, 11], const checkboxes = screen.getAllByRole('checkbox');
initialAssociatedEnvironmentIds: [10, 11], await user.click(checkboxes[1]);
onChange,
await waitFor(() => {
expect(screen.getByTestId('remove-environments-button')).toBeEnabled();
}); });
// Wait for initial query to load and row to appear in Associated table, then click
const associatedTable = await screen.findByTestId(
'group-associatedEndpoints'
);
const envRow =
await within(associatedTable).findByText('associated-env-1');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([11]);
}); });
it('should call onChange with empty array when removing last environment', async () => { it('calls PUT with filtered environment IDs after confirming remove', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); let requestBody: unknown;
const envs = [createEnv(10, 'only-env')]; setupMockServer({
setupMockServer(envs); associatedEnvs: [createEnv(10, 'env-a'), createEnv(11, 'env-b')],
onPut: (body) => {
renderComponent({ requestBody = body;
associatedEnvironmentIds: [10], },
initialAssociatedEnvironmentIds: [10],
onChange,
}); });
renderComponent();
const associatedTable = await screen.findByTestId( await screen.findByText('env-a');
'group-associatedEndpoints'
);
const envRow = await within(associatedTable).findByText('only-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([]); // Select first row checkbox
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]);
const removeBtn = await screen.findByTestId('remove-environments-button');
await waitFor(() => expect(removeBtn).toBeEnabled());
await user.click(removeBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: [11],
});
});
}); });
}); });
describe('Computed values', () => { describe('Adding environments (drawer)', () => {
it('should identify added IDs (current but not initial)', () => { it('opens the drawer when Add button is clicked', async () => {
// addedIds = associatedEnvironmentIds.filter(id => !initialAssociatedEnvironmentIds.includes(id)) const user = userEvent.setup();
// When current=[1,2,3] and initial=[2,3], added=[1] setupMockServer({ availableEnvs: [createEnv(20, 'available-env')] });
setupMockServer(); renderComponent();
// This test validates the component's internal logic by checking the highlightIds const addBtn = await screen.findByTestId('add-environments-button');
// passed to AssociatedEnvironmentsTable (newly added envs get "Unsaved" badge) await user.click(addBtn);
renderComponent({
associatedEnvironmentIds: [1, 2, 3],
initialAssociatedEnvironmentIds: [2, 3],
});
// The component will compute addedIds=[1] internally expect(await screen.findByText('Add environments')).toBeVisible();
// We can't directly test internal state, but we verify it renders
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
}); });
it('should identify removed IDs (initial but not current)', () => { it('calls PUT with merged IDs when environments are added from drawer', async () => {
// removedIds = initialAssociatedEnvironmentIds.filter(id => !associatedEnvironmentIds.includes(id)) const user = userEvent.setup();
// When current=[2,3] and initial=[1,2,3], removed=[1] let requestBody: unknown;
setupMockServer();
renderComponent({ setupMockServer({
associatedEnvironmentIds: [2, 3], associatedEnvs: [createEnv(10, 'existing-env')],
initialAssociatedEnvironmentIds: [1, 2, 3], availableEnvs: [createEnv(20, 'new-env')],
onPut: (body) => {
requestBody = body;
},
}); });
renderComponent();
// The component will compute removedIds=[1] internally // Open drawer
// and pass it as includeIds to AvailableEnvironmentsTable const addBtn = await screen.findByTestId('add-environments-button');
expect(screen.getByTestId('group-availableEndpoints')).toBeVisible(); await user.click(addBtn);
// Wait for drawer to open and available env to appear
await screen.findByText('Add environments');
await screen.findByText('new-env');
// Select the available env — find the drawer's checkboxes
// The drawer table has its own checkboxes after the main table ones
const allCheckboxes = screen.getAllByRole('checkbox');
// Last checkbox belongs to the drawer table row
await user.click(allCheckboxes[allCheckboxes.length - 1]);
// Click the Add button in the drawer footer
const confirmAddBtn = screen.getByTestId(
'add-environments-confirm-button'
);
await user.click(confirmAddBtn);
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: expect.arrayContaining([10, 20]),
});
});
}); });
}); });
}); });
@@ -1,126 +1,94 @@
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection'; import { openConfirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { Environment, EnvironmentId } from '../../../types'; import { useGroup } from '../../queries/useGroup';
import { useUpdateGroupMutation } from '../../queries/useUpdateGroupMutation';
import { EnvironmentTableData } from './types'; import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable'; import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AvailableEnvironmentsTable } from './AvailableEnvironmentsTable'; import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
interface Props { interface Props {
/** Group ID when editing an existing group */ groupId: EnvironmentGroupId;
groupId?: number; /* For unassigned group, don't show the add/remove buttons and hide the checkbox */
/** IDs of currently associated environments */ readOnly: boolean;
associatedEnvironmentIds: Array<EnvironmentId>;
/** IDs of initially associated environments for tracking unsaved changes */
initialAssociatedEnvironmentIds: Array<EnvironmentId>;
/** Called when environment IDs change */
onChange: (ids: Array<EnvironmentId>) => void;
} }
export function AssociatedEnvironmentsSelector({ export function AssociatedEnvironmentsSelector({ groupId, readOnly }: Props) {
groupId, const [drawerOpen, setDrawerOpen] = useState(false);
associatedEnvironmentIds,
initialAssociatedEnvironmentIds,
onChange,
}: Props) {
// Track full environment objects for display (populated when clicking rows)
const [environmentCache, setEnvironmentCache] = useState<
Map<EnvironmentId, EnvironmentTableData>
>(new Map());
// Fetch initially associated environments to populate the cache const groupQuery = useGroup(groupId);
const initialEnvsQuery = useEnvironmentList( const environmentsQuery = useEnvironmentList({
groupId groupIds: [groupId],
? { pageLimit: 0,
groupIds: [groupId], });
pageLimit: 0, const updateMutation = useUpdateGroupMutation();
}
: {
endpointIds: initialAssociatedEnvironmentIds,
},
{
enabled: groupId
? groupId !== 1
: initialAssociatedEnvironmentIds.length > 0,
}
);
const environmentMap = useMemo( const currentEnvironments = environmentsQuery.environments ?? [];
() => buildEnvironmentMap(environmentCache, initialEnvsQuery.environments), const currentIds = currentEnvironments.map((e) => e.Id);
[environmentCache, initialEnvsQuery.environments]
);
const associatedSet = new Set(associatedEnvironmentIds);
const initialSet = new Set(initialAssociatedEnvironmentIds);
const addedIds = associatedEnvironmentIds.filter((id) => !initialSet.has(id));
const removedIds = initialAssociatedEnvironmentIds.filter(
(id) => !associatedSet.has(id)
);
const excludeIdsForAvailableEnvironments = groupId
? addedIds
: associatedEnvironmentIds;
const associatedEnvironments = associatedEnvironmentIds
.map((id) => environmentMap.get(id))
.filter((env): env is Environment => env !== undefined);
return ( return (
<FormSection title="Associated environments"> <>
<div className="small text-muted"> <AssociatedEnvironmentsTable
You can select which environment should be part of this group by moving title="Associated environments"
them to the associated environments table. Simply click on any environments={currentEnvironments}
environment entry to move it from one table to the other. isLoading={environmentsQuery.isLoading}
</div> onRemove={handleRemove}
onOpenAddDrawer={() => setDrawerOpen(true)}
isRemoving={updateMutation.isLoading}
data-cy="group-associatedEndpoints"
readOnly={readOnly}
/>
<div className="flex mt-4 gap-5 items-stretch"> <AddEnvironmentsDrawer
<div className="w-1/2 flex flex-col"> open={drawerOpen}
<AvailableEnvironmentsTable onClose={() => setDrawerOpen(false)}
title="Available environments" excludeGroupIds={[groupId]}
excludeIds={excludeIdsForAvailableEnvironments} onAdd={handleAdd}
includeIds={removedIds} isLoading={updateMutation.isLoading}
highlightIds={removedIds} />
onClickRow={handleAddEnvironment} </>
data-cy="group-availableEndpoints"
/>
</div>
<div className="w-1/2 flex flex-col">
<AssociatedEnvironmentsTable
title="Associated environments"
environments={associatedEnvironments}
highlightIds={addedIds}
onClickRow={handleRemoveEnvironment}
data-cy="group-associatedEndpoints"
/>
</div>
</div>
</FormSection>
); );
function handleAddEnvironment(env: EnvironmentTableData) { function handleRemove(selected: EnvironmentTableData[]) {
if (!associatedEnvironmentIds.includes(env.Id)) { const selectedIds = new Set(selected.map((e) => e.Id));
setEnvironmentCache((prev) => new Map(prev).set(env.Id, env)); const remainingIds = currentIds.filter((id) => !selectedIds.has(id));
onChange([...associatedEnvironmentIds, env.Id]);
} updateMutation.mutate({
id: groupId,
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description,
tagIds: groupQuery.data?.TagIds,
associatedEnvironments: remainingIds,
});
} }
function handleRemoveEnvironment(env: EnvironmentTableData) { async function handleAdd(newEnvs: EnvironmentTableData[]): Promise<boolean> {
onChange(associatedEnvironmentIds.filter((id) => id !== env.Id)); const confirmed = await openConfirm({
title: 'Are you sure?',
message: `Are you sure you want to add the selected environment(s) to this group?`,
confirmButton: buildConfirmButton('Add'),
});
if (!confirmed) return false;
const mergedIds = [
...new Set([...currentIds, ...newEnvs.map((e) => e.Id)]),
];
updateMutation.mutate({
id: groupId,
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description,
tagIds: groupQuery.data?.TagIds,
associatedEnvironments: mergedIds,
});
return true;
} }
} }
function buildEnvironmentMap(
cache: Map<EnvironmentId, EnvironmentTableData>,
envs: Array<Environment> | undefined
): Map<EnvironmentId, EnvironmentTableData> {
return new Map([
...cache.entries(),
...(envs ?? []).map(
(env) => [env.Id, { Name: env.Name, Id: env.Id }] as const
),
]);
}
@@ -1,15 +1,17 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import clsx from 'clsx';
import { truncate } from 'lodash'; import { truncate } from 'lodash';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import clsx from 'clsx';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState'; import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables'; import { Datatable } from '@@/datatables';
import { Badge } from '@@/Badge'; import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
import { Widget } from '@@/Widget'; import { TableRow } from '@@/datatables/TableRow';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { Button } from '@@/buttons';
import { EnvironmentTableData } from './types'; import { EnvironmentTableData } from './types';
@@ -18,64 +20,101 @@ const columnHelper = createColumnHelper<EnvironmentTableData>();
interface Props extends AutomationTestingProps { interface Props extends AutomationTestingProps {
title: string; title: string;
environments: Array<EnvironmentTableData>; environments: Array<EnvironmentTableData>;
onClickRow?: (env: EnvironmentTableData) => void; onRemove(selected: EnvironmentTableData[]): void;
highlightIds?: Array<EnvironmentId>; onOpenAddDrawer(): void;
isRemoving?: boolean;
isLoading?: boolean;
/** When false, Remove fires immediately without a confirmation dialog (e.g. create mode) */
confirmRemove?: boolean;
/** When true, don't show the add/remove buttons and hide the checkbox */
readOnly?: boolean;
} }
export function AssociatedEnvironmentsTable({ export function AssociatedEnvironmentsTable({
title, title,
environments, environments,
onClickRow, onRemove,
highlightIds = [], onOpenAddDrawer,
isRemoving,
isLoading,
confirmRemove = true,
readOnly = false,
'data-cy': dataCy, 'data-cy': dataCy,
}: Props) { }: Props) {
const tableState = useTableStateWithoutStorage('Name'); const tableState = useTableStateWithoutStorage('Name');
const columns = useMemo(() => buildColumns(highlightIds), [highlightIds]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const columns = useMemo(() => buildColumns(), []);
return ( return (
<Widget className="flex-1 flex flex-col"> // avoid padding issues with the widget
<div <div className="-mx-[15px]">
className={clsx( <Datatable<EnvironmentTableData>
'h-full flex flex-col', disableSelect={readOnly}
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col', isLoading={isLoading}
'[&_.footer]:!mt-auto' title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
getRowId={(row) => String(row.Id)}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={() => row.toggleSelected()}
className={clsx({ active: row.getIsSelected() })}
aria-selected={row.getIsSelected()}
/>
)} )}
> extendTableOptions={withControlledSelected(setSelectedIds, selectedIds)}
<Datatable<EnvironmentTableData> renderTableActions={(selectedItems) =>
// noWidget to avoid padding issues with TableContainer readOnly ? null : (
noWidget <>
title={title} {confirmRemove ? (
columns={columns} <DeleteButton
settingsManager={tableState} disabled={selectedItems.length === 0}
dataset={environments} isLoading={isRemoving}
renderRow={(row) => ( confirmMessage="Are you sure you want to remove the selected environment(s) from this group?"
<TableRow<EnvironmentTableData> onConfirmed={() => handleRemove(selectedItems)}
cells={row.getVisibleCells()} data-cy="remove-environments-button"
onClick={onClickRow ? () => onClickRow(row.original) : undefined} type="button"
/> />
)} ) : (
disableSelect <DeleteButton
data-cy={dataCy || 'environment-table'} disabled={selectedItems.length === 0}
/> onClick={() => {
</div> handleRemove(selectedItems);
</Widget> }}
data-cy="remove-environments-button"
type="button"
/>
)}
<Button
icon={Plus}
onClick={onOpenAddDrawer}
data-cy="add-environments-button"
>
Add
</Button>
</>
)
}
data-cy={dataCy || 'environment-table'}
/>
</div>
); );
function handleRemove(selectedItems: EnvironmentTableData[]) {
onRemove(selectedItems);
setSelectedIds([]);
}
} }
function buildColumns(highlightIds: Array<EnvironmentId>) { function buildColumns() {
return [ return [
columnHelper.accessor('Name', { columnHelper.accessor('Name', {
header: 'Name', header: 'Name',
id: 'Name', id: 'Name',
cell: ({ getValue, row }) => ( cell: ({ getValue }) => (
<span className="flex items-center gap-2"> <span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.includes(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
), ),
}), }),
]; ];
@@ -1,176 +0,0 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import clsx from 'clsx';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import { semverCompare } from '@/react/common/semver-utils';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { Badge } from '@@/Badge';
import { Widget } from '@@/Widget';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const tableKey = 'available-environments';
const settingsStore = createPersistedStore(tableKey, 'Name');
interface Props extends AutomationTestingProps {
title: string;
/** IDs to exclude from the query (environments already associated) */
excludeIds: Array<EnvironmentId>;
/** IDs to include in the query (e.g., recently removed from associated - will be highlighted) */
includeIds?: Array<EnvironmentId>;
/** IDs to highlight (unsaved badge) */
highlightIds?: Array<EnvironmentId>;
onClickRow?: (env: EnvironmentTableData) => void;
}
export function AvailableEnvironmentsTable({
title,
excludeIds,
includeIds = [],
highlightIds = [],
onClickRow,
'data-cy': dataCy,
}: Props) {
const tableState = useTableState(settingsStore, tableKey);
const [page, setPage] = useState(0);
const columns = useMemo(
() => buildColumns(new Set(highlightIds)),
[highlightIds]
);
// Query unassigned environments (group 1)
const unassignedQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
});
// Query removed environments by ID (these are still in their original group until saved)
const removedQuery = useEnvironmentList(
{
endpointIds: includeIds,
search: tableState.search,
},
{ enabled: includeIds.length > 0 }
);
// Merge results: removed environments + unassigned environments (deduped)
const { environments, uniqueRemovedCount } = useMemo(() => {
const unassigned = unassignedQuery.environments || [];
const removed =
includeIds.length > 0 ? removedQuery.environments || [] : [];
if (removed.length === 0) {
return { environments: unassigned, uniqueRemovedCount: 0 };
}
const unassignedIds = new Set(unassigned.map((e) => e.Id));
const uniqueRemoved = removed.filter((e) => !unassignedIds.has(e.Id));
// Sort combined results by name to maintain order
const combined = [...uniqueRemoved, ...unassigned];
const isDesc = tableState.sortBy?.desc ?? false;
// useTypeGuard on tableState.sortBy.id to use as a key for sorting
const sortKey = getSortKey(tableState.sortBy?.id);
if (sortKey) {
return {
environments: combined.sort((a, b) => {
const cmp = semverCompare(
a[sortKey].toString(),
b[sortKey].toString()
);
return isDesc ? -cmp : cmp;
}),
uniqueRemovedCount: uniqueRemoved.length,
};
}
return { environments: combined, uniqueRemovedCount: uniqueRemoved.length };
}, [
unassignedQuery.environments,
removedQuery.environments,
includeIds.length,
tableState.sortBy?.desc,
tableState.sortBy?.id,
]);
const totalCount = unassignedQuery.totalCount + uniqueRemovedCount;
return (
<Widget className="flex-1 flex flex-col">
<div
className={clsx(
'h-full flex flex-col',
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
'[&_.footer]:!mt-auto'
)}
>
<Datatable<EnvironmentTableData>
// noWidget to avoid padding issues with TableContainer
noWidget
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
disableSelect
data-cy={dataCy || 'available-environments-table'}
/>
</div>
</Widget>
);
}
function buildColumns(highlightIds: Set<EnvironmentId>) {
return [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue, row }) => (
<span className="flex items-center gap-2">
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.has(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
),
}),
];
}
function getSortKey(sortId?: string): keyof EnvironmentTableData | undefined {
if (!sortId) {
return undefined;
}
switch (sortId) {
case 'Name':
return 'Name';
default:
return 'Name';
}
// extend to other keys as needed
}
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AddEnvironmentsDrawer } from './AddEnvironmentsDrawer';
interface Props {
selectedIds: EnvironmentId[];
onChange(ids: EnvironmentId[]): void;
}
/**
* Similar to AssociatedEnvironmentsSelector, but instead of making API calls and getting the environment list from the server, it holds the the selected environments and ids in local state.
*
* This is because on create, there is no group to add / remove environments from yet.
*/
export function FormModeEnvironmentsSelector({ selectedIds, onChange }: Props) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedEnvironments, setSelectedEnvironments] = useState<
EnvironmentTableData[]
>([]);
return (
<FormSection title="Associate environments">
<p className="small text-muted">
Assocate environments to this group by clicking the add button below.
</p>
<AssociatedEnvironmentsTable
title="Associated environments"
environments={selectedEnvironments}
onRemove={handleRemove}
onOpenAddDrawer={() => setDrawerOpen(true)}
confirmRemove={false}
data-cy="group-associatedEndpoints"
/>
<AddEnvironmentsDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
excludeIds={selectedIds}
onAdd={handleAdd}
/>
</FormSection>
);
async function handleRemove(toRemove: EnvironmentTableData[]) {
const removeIds = new Set(toRemove.map((env) => env.Id));
setSelectedEnvironments((prev) => prev.filter((e) => !removeIds.has(e.Id)));
onChange(selectedIds.filter((id) => !removeIds.has(id)));
}
function handleAdd(newEnvs: EnvironmentTableData[]) {
const existingIds = new Set(selectedIds);
const toAdd = newEnvs.filter((e) => !existingIds.has(e.Id));
setSelectedEnvironments((prev) => [...prev, ...toAdd]);
onChange([...selectedIds, ...toAdd.map((e) => e.Id)]);
}
}

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