Compare commits

...

103 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
Chaim Lev-Ari 9fcac1ab4f chore(deps): upgrade axios [BE-12632] (#1864) 2026-02-19 15:38:08 +13:00
Josiah Clumont ae24ad4693 Bump version to 2.39.0 for LTS (#1910) 2026-02-19 15:29:08 +13:00
RHCowan 0f721b60a9 fix(policy) Improve policy status performance [R8S-710] (#1878) 2026-02-19 15:24:14 +13:00
RHCowan e8b49f53e1 fix(policy) fix policy group pagination issues [R8S-855] (#1898) 2026-02-19 13:29:01 +13:00
andres-portainer 27531a802b fix(fips): ensure custom registries cannot use HTTP without TLS BE-12511 (#1885)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2026-02-19 11:51:11 +13:00
Josiah Clumont 4bbf0ce0c0 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.39.0-LTS [R8S-818] (#1791) 2026-02-19 09:46:14 +13:00
Josiah Clumont e0c22ea3eb fix(copy): Fixed an issue with the downgrade links [R8S-832] (#1907) 2026-02-19 09:38:04 +13:00
nickl-portainer b7eb2ba068 fix(policies) convert all warnings to use PolicyOverrideAlert [R8S-837] (#1890) 2026-02-19 09:12:54 +13:00
Ali affdb69568 fix(policies): show registry policy banner, and disable registry selector when policy applies [R8S-853] (#1891) 2026-02-19 08:37:02 +13:00
LP B 763b7da65c fix(api/docker): do not rewrite HTTP code in responses of create requests (#1854) 2026-02-18 19:26:29 +01:00
Chaim Lev-Ari 42e9165347 fix(stacks): generate webhook id for stacks (#1876) 2026-02-17 10:38:18 +02:00
Ali 16dd08a359 feat(widget): update widget tab styling product wide [r8s-850] (#1881) 2026-02-17 10:33:43 +13:00
Ali 936494615c fix(select): stop react-select overlapping with footer [R8S-794] (#1880) 2026-02-17 08:53:50 +13:00
andres-portainer 5769c0b98e fix(kubernetes): add missing returns BE-12582 (#1883) 2026-02-16 12:47:27 -03:00
andres-portainer b7e1caa8c6 fix(boltdb): fix error handling BE-12582 (#1882) 2026-02-16 12:47:00 -03:00
andres-portainer e02ae6b2fb fix(archive): prevent file traversal vulnerability BE-12582 (#1875) 2026-02-16 11:26:51 -03:00
testA113 d9f131a2c5 Revert "feat(widget): update widget tab styling product wide [r8s-850]"
This reverts commit d882c3b8fa4a03bf85b4e9fb1da729fabf903cb6.
2026-02-17 00:05:24 +13:00
testA113 ad1f7dbaa5 feat(widget): update widget tab styling product wide [r8s-850] 2026-02-17 00:01:07 +13:00
Devon Steenberg aa6da0f6d3 feat(api-testing): add api testing framework [BE-12571] (#1824)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-02-16 09:35:06 +13:00
Oscar Zhou 376071e408 feat(edge/helm): add atomic and timeout options [BE-12481] (#1849) 2026-02-16 09:21:19 +13:00
Chaim Lev-Ari d3544fb9b3 refactor(tests): mock ws server (#1853) 2026-02-15 08:58:24 +02:00
Chaim Lev-Ari c8497b3944 chore(deps): upgrade html-loader (#1863) 2026-02-15 08:55:33 +02:00
andres-portainer 5aa92b8413 fix(webhooks): use transactions to check for webhook uniqueness BE-12613 (#1872) 2026-02-13 12:48:17 -03:00
Hannah Cooper bccb6694d4 Update bug_report to include 2.38.1 (#1866) 2026-02-13 12:42:08 +13:00
Hannah Cooper 506a11c658 Update bug_report to include 2.33.7 (#1836) 2026-02-13 12:28:05 +13:00
Ali bdc315a59d fix(helm): helm release not found error [r8s-842] (#1857) 2026-02-13 08:07:23 +13:00
andres-portainer ec7d3bddfc fix(endpoints): fix transaction usage BE-12612 (#1838) 2026-02-11 12:34:46 -03:00
Chaim Lev-Ari 762c1ccf28 chore(deps): upgrade vitest and msw (#1852) 2026-02-11 17:14:04 +02:00
Malcolm Lockyer 8e44c8fa06 fix(webpack): fix common cfg after webpack-dev-server upgrade [r8s-841] (#1848) 2026-02-11 18:34:14 +13:00
Chaim Lev-Ari 20db102327 chore(deps): upgrade webpack (#1802) 2026-02-10 18:01:03 +02:00
Chaim Lev-Ari 1643cb8165 fix(environments): handle unix:// urls [BE-12610] (#1837)
Co-authored-by: Nicholas Loomans <nicholas.loomans@portainer.io>
2026-02-10 15:21:25 +02:00
Ali 49e623dfeb feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1718) 2026-02-10 23:44:44 +13:00
Steven Kang a1208974ac fix(policy): pod security constraints - develop [R8S-808] (#1758)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-02-10 08:46:02 +09:00
Chaim Lev-Ari d611087513 chore(deps): upgrade storybook 8 (#1811) 2026-02-08 09:59:08 +02:00
andres-portainer ac7cb2ee19 fix(security): fix CVE-2025-68121 by upgrading Go compiler BE-12581 (#1813) 2026-02-06 13:17:12 -03:00
Oscar Zhou f866572cbf fix(edge/helm): helm config section shows for other type [BE-12580] (#1808) 2026-02-06 09:13:06 +13:00
Chaim Lev-Ari 4c6942f60b fix(environments): update associated group [BE-12559] (#1760) 2026-02-05 18:48:02 +02:00
nickl-portainer d939897524 feat(menu) add policies to environment settings submenu [R8S-806] (#1805) 2026-02-05 14:39:41 +13:00
nickl-portainer 66c5589fd7 fix(environment-list) resize kubeconfig download modal [R8S-814] (#1786)
Co-authored-by: Phil Calder <4473109+predlac@users.noreply.github.com>
Co-authored-by: Steven Kang <skan070@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
2026-02-05 14:39:23 +13:00
Oscar Zhou 379b1d611b feat(edge/helm): support helm chart via git repository in edge stack [BE-12448] (#1649) 2026-02-05 13:22:31 +13:00
Chaim Lev-Ari f16221f385 docs(claude): optimize memory files (#1777) 2026-02-05 04:28:36 +05:30
RHCowan 9b82560270 fix(policy) Fetch new status after policy update [R8S-711] (#1775) 2026-02-04 18:23:26 +13:00
Oscar Zhou 7271af03e6 fix(docker): dashboard api return 500 error [BE-12567] (#1784) 2026-02-04 08:32:01 +13:00
RHCowan 4d564bbce2 feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) 2026-02-03 12:32:22 +13:00
Oscar Zhou d7afdf214b refactor(k8s): replace kubectl delete with delete api [BE-12560] (#1768) 2026-02-03 08:36:08 +13:00
Chaim Lev-Ari 18e445ea02 refactor(environments): migrate item view to react [BE-6632] (#1747) 2026-01-31 15:05:11 +07:00
nickl-portainer cb70c705a3 fix(react): namespace selects sort alphabetically [R8S-765] (#1671) 2026-01-30 08:23:01 +13:00
Ali 9a77eb9872 chore(environment-groups): migrate environment groups to react [R8S-771] (#1741) 2026-01-29 14:17:33 +13:00
Hannah Cooper ec82f646a0 Add 2.38.0 to bug report (#1756) 2026-01-29 12:45:23 +13:00
andres-portainer 2f0e384240 fix(database): use Exists() where possible to improve performance BE-12557 (#1752) 2026-01-28 18:49:32 -03:00
Ali 19a1426869 chore(webpack): cache dependencies and use lighter sourcemap [R8S-791] (#1715) 2026-01-29 09:52:11 +13:00
andres-portainer cc5cd8db6b fix(pendingactions): clean up and optimize the code BE-12556 (#1750) 2026-01-28 15:36:54 -03:00
andres-portainer e384e2edda fix(pendingactions): fix transaction handling BE-12556 (#1749) 2026-01-28 14:11:35 -03:00
Chaim Lev-Ari dca044873f feat(environments): migrate edge form to react BE-12529 (#1676) 2026-01-28 15:35:13 +07:00
nickl-portainer 8aadddcc68 test(react): add test coverage for forms to enforce no errors showing on initial load [R8S-730] (#1696) 2026-01-28 08:12:12 +13:00
andres-portainer 2e95229c51 fix(oauth): add a timeout to GetResource() BE-12258 (#1456) 2026-01-27 10:24:45 -03:00
328 changed files with 15443 additions and 9499 deletions
+9 -2
View File
@@ -114,6 +114,9 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
'@typescript-eslint/switch-exhaustiveness-check': 'error'
'consistent-return': 'off'
'default-case': off
'jsx-a11y/label-has-associated-control':
- error
- assert: either
@@ -139,15 +142,18 @@ overrides:
'react/jsx-props-no-spreading': off
- files:
- app/**/*.test.*
plugins:
- '@vitest'
extends:
- 'plugin:vitest/recommended'
- 'plugin:@vitest/legacy-recommended'
env:
'vitest/env': true
'@vitest/env': true
rules:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'@vitest/no-conditional-expect': warn
- files:
- app/**/*.stories.*
rules:
@@ -155,3 +161,4 @@ overrides:
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'storybook/no-renderer-packages': off
+3
View File
@@ -94,10 +94,13 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.38.1'
- '2.38.0'
- '2.37.0'
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.7'
- '2.33.6'
- '2.33.5'
- '2.33.4'
+1 -1
View File
@@ -6,7 +6,7 @@ linters:
settings:
forbidigo:
forbid:
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
msg: Use a transaction instead
analyze-types: true
exclusions:
+2 -1
View File
@@ -1,2 +1,3 @@
dist
api/datastore/test_data
api/datastore/test_data
coverage
+38 -18
View File
@@ -1,6 +1,7 @@
import path from 'path';
import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
import postcss from 'postcss';
@@ -9,20 +10,38 @@ const config: StorybookConfig = {
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-webpack5-compiler-swc',
'@chromatic-com/storybook',
{
name: '@storybook/addon-styling',
name: '@storybook/addon-styling-webpack',
options: {
cssLoaderOptions: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
rules: [
{
test: /\.css$/,
sideEffects: true,
use: [
require.resolve('style-loader'),
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: postcss,
},
},
],
},
},
postCss: {
implementation: postcss,
},
],
},
},
],
@@ -67,12 +86,7 @@ const config: StorybookConfig = {
...config,
resolve: {
...config.resolve,
plugins: [
...(config.resolve?.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve?.extensions,
}),
],
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
},
module: {
...config.module,
@@ -83,11 +97,17 @@ const config: StorybookConfig = {
staticDirs: ['./public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
compilerOptions: {
outDir: path.resolve(__dirname, '..', 'dist/public'),
},
},
},
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {},
};
export default config;
+17 -18
View File
@@ -1,9 +1,9 @@
import '../app/assets/css';
import React from 'react';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Preview } from '@storybook/react';
initMSW(
{
@@ -21,31 +21,30 @@ initMSW(
handlers
);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
};
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
export const decorators = [
(Story) => (
const preview: Preview = {
decorators: (Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
];
loaders: [mswLoader],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
},
};
export const loaders = [mswLoader];
export default preview;
+48
View File
@@ -0,0 +1,48 @@
# Portainer Community Edition
Open-source container management platform with full Docker and Kubernetes support.
see also:
- docs/guidelines/server-architecture.md
- docs/guidelines/go-conventions.md
- docs/guidelines/typescript-conventions.md
## Package Manager
- **PNPM** 10+ (for frontend)
- **Go** 1.25.8 (for backend)
## Build Commands
```bash
# Full build
make build # Build both client and server
make build-client # Build React/AngularJS frontend
make build-server # Build Go binary
make build-image # Build Docker image
# Development
make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
# Frontend
pnpm dev # Webpack dev server
pnpm build # Build frontend with webpack
pnpm typecheck # Run typecheck for frontend (with tsc)
pnpm lint # lint frontend (with eslint)
pnpm test # test frontend (with vitest)
pnpm format # format frontend (with prettier)
# Testing
make test # All tests (backend + frontend)
make test-server # Backend tests only
make lint # Lint all code
make format # Format code
```
## Development Servers
- Frontend: http://localhost:8999
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
+5 -2
View File
@@ -4,7 +4,8 @@ WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
GOTESTSUM_VERSION?=v1.13.0
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -57,8 +58,10 @@ test: test-server test-client ## Run all tests
test-client: ## Run client tests
pnpm run test $(ARGS) --coverage
TEST_PACKAGES?=./...
test-server: ## Run server tests
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out ./...
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
##@ Dev
.PHONY: dev dev-client dev-server
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
)
@@ -108,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
case tar.TypeDir:
// skip, dir will be created with a file
case tar.TypeReg:
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
p := filesystem.JoinPaths(outputDirPath, header.Name)
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
+56
View File
@@ -1,12 +1,15 @@
package archive
import (
"archive/tar"
"compress/gzip"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -108,3 +111,56 @@ func Test_shouldCreateArchive2(t *testing.T) {
wasExtracted("dir/inner")
wasExtracted("dir/.dotfile")
}
func TestExtractTarGzPathTraversal(t *testing.T) {
testDir := t.TempDir()
// Create an evil file with a path traversal attempt
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
evilFile, err := os.Create(tarPath)
require.NoError(t, err)
gzWriter := gzip.NewWriter(evilFile)
tarWriter := tar.NewWriter(gzWriter)
content := []byte("evil content")
header := &tar.Header{
Name: "../evil.txt",
Mode: 0600,
Size: int64(len(content)),
Typeflag: tar.TypeReg,
}
err = tarWriter.WriteHeader(header)
require.NoError(t, err)
_, err = tarWriter.Write(content)
require.NoError(t, err)
err = tarWriter.Close()
require.NoError(t, err)
err = gzWriter.Close()
require.NoError(t, err)
err = evilFile.Close()
require.NoError(t, err)
// Attempt to extract the evil file
extractionDir := filesystem.JoinPaths(testDir, "extraction")
err = os.Mkdir(extractionDir, 0700)
require.NoError(t, err)
tarFile, err := os.Open(tarPath)
require.NoError(t, err)
// Check that the file didn't escape
err = ExtractTarGz(tarFile, extractionDir)
require.NoError(t, err)
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
err = tarFile.Close()
require.NoError(t, err)
}
+4 -4
View File
@@ -8,8 +8,8 @@ import (
"time"
)
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
if err != nil {
return
}
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
return
}
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
if err != nil {
return
}
+9 -2
View File
@@ -94,13 +94,20 @@ func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags.TLSKey = tlsKeyFlag.String()
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
flags.KubectlShellImage = kingpin.Flag(
var hasKubectlShellImageFlag bool
kubectlShellImageFlag := kingpin.Flag(
"kubectl-shell-image",
"Kubectl shell image",
).Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String()
).Envar(portainer.KubectlShellImageEnvVar).
Default(portainer.DefaultKubectlShellImage).
IsSetByUser(&hasKubectlShellImageFlag)
flags.KubectlShellImage = kubectlShellImageFlag.String()
kingpin.Parse()
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
+54
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -26,6 +27,59 @@ func TestOptionParser(t *testing.T) {
require.True(t, *opts.EnableEdgeComputeFeatures)
}
func TestParseKubectlShellImageFlag(t *testing.T) {
tests := []struct {
name string
args []string
envVars map[string]string
expectedKubectlShellImageSet bool
expectedKubectlShellFlag string
}{
{
name: "no flag, no env var",
expectedKubectlShellImageSet: false,
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
},
{
name: "explicit flag",
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v2",
},
{
name: "env var",
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v3",
},
{
name: "both env var and flag set",
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
expectedKubectlShellImageSet: true,
expectedKubectlShellFlag: "myimage:v2",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.args == nil {
tc.args = []string{"portainer"}
}
setOsArgs(t, tc.args)
for k, v := range tc.envVars {
t.Setenv(k, v)
}
flags, err := Service{}.ParseFlags("test-version")
require.NoError(t, err)
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
})
}
}
func TestParseTLSFlags(t *testing.T) {
testCases := []struct {
name string
+4
View File
@@ -248,6 +248,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if flags.KubectlShellImageSet {
settings.KubectlShellImage = *flags.KubectlShellImage
}
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
}
+63 -1
View File
@@ -5,6 +5,9 @@ import (
"path"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +15,7 @@ import (
const secretFileName = "secret.txt"
func createPasswordFile(t *testing.T, secretPath, password string) string {
err := os.WriteFile(secretPath, []byte(password), 0600)
err := os.WriteFile(secretPath, []byte(password), 0o600)
require.NoError(t, err)
return secretPath
}
@@ -38,6 +41,65 @@ func TestLoadEncryptionSecretKey(t *testing.T) {
require.Len(t, encryptionKey, 32)
}
func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
const existingImage = "existing-image:v1"
const newImage = "new-image:v2"
emptyString := ""
falseBool := false
var emptyLabels []portainer.Pair
tests := []struct {
name string
imageSet bool
flagImage string
expectedKubectlShellImage string
}{
{
name: "flag not set — DB image unchanged",
imageSet: false,
flagImage: portainer.DefaultKubectlShellImage,
expectedKubectlShellImage: existingImage,
},
{
name: "flag set — DB image updated",
imageSet: true,
flagImage: newImage,
expectedKubectlShellImage: newImage,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := testhelpers.NewDatastore(
testhelpers.WithSettingsService(&portainer.Settings{
KubectlShellImage: existingImage,
}),
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
)
flags := &portainer.CLIFlags{
SnapshotInterval: &emptyString,
Logo: &emptyString,
EnableEdgeComputeFeatures: &falseBool,
Templates: &emptyString,
Labels: &emptyLabels,
HTTPDisabled: &falseBool,
HTTPEnabled: &falseBool,
}
flags.KubectlShellImage = &tc.flagImage
flags.KubectlShellImageSet = tc.imageSet
err := updateSettingsFromFlags(store, flags)
require.NoError(t, err)
settings, err := store.Settings().Settings()
require.NoError(t, err)
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
})
}
}
func TestDBSecretPath(t *testing.T) {
tests := []struct {
keyFilenameFlag string
+3 -1
View File
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
}
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
if !config.TLS {
if !config.TLS && fipsEnabled {
return nil, fips.ErrTLSRequired
} else if !config.TLS {
return nil, nil
}
+2 -2
View File
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
}
}
if e := json.Unmarshal(data, object); e != nil {
if err := json.Unmarshal(data, object); err != nil {
// Special case for the VERSION bucket. Here we're not using json
// So we need to return it as a string
s, ok := object.(*string)
if !ok {
return errors.Wrap(err, e.Error())
return errors.Wrap(err, "Failed unmarshalling object")
}
*s = string(data)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
var (
ErrObjectNotFound = errors.New("object not found inside the database")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
ErrDBImportFailed = errors.New("importing backup failed")
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
)
+7
View File
@@ -31,6 +31,13 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
service: service,
tx: tx,
}
}
// Settings retrieve the ssl settings object.
func (service *Service) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
+31
View File
@@ -0,0 +1,31 @@
package ssl
import (
portainer "github.com/portainer/portainer/api"
)
type ServiceTx struct {
service *Service
tx portainer.Transaction
}
func (service ServiceTx) BucketName() string {
return BucketName
}
// Settings retrieve the settings object.
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
err := service.tx.GetObject(BucketName, []byte(key), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// UpdateSettings persists a Settings object.
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
return service.tx.UpdateObject(BucketName, []byte(key), settings)
}
+1
View File
@@ -59,6 +59,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
KubectlShellImage: *store.flags.KubectlShellImage,
IsDockerDesktopExtension: isDDExtention,
EnforceEdgeID: true,
}
return store.SettingsService.UpdateSettings(defaultSettings)
+1 -1
View File
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
// In this particular instance we should log a fatal error
if m.CurrentDBEdition() != portainer.PortainerCE {
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
return false
}
+3 -1
View File
@@ -74,7 +74,9 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
return tx.store.SnapshotService.Tx(tx.tx)
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
return tx.store.SSLSettingsService.Tx(tx.tx)
}
func (tx *StoreTx) Stack() dataservices.StackService {
return tx.store.StackService.Tx(tx.tx)
@@ -613,7 +613,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.38.0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.2",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -942,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.38.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
}
+14 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
)
@@ -35,8 +36,10 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
var aggErr error
var aggMu sync.Mutex
var processedCount int
for i := range containers {
id := containers[i].ID
semaphore <- struct{}{}
wg.Go(func() {
defer func() { <-semaphore }()
@@ -44,8 +47,17 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
containerInspection, err := cli.ContainerInspect(ctx, id)
stat := ContainerStats{}
if err != nil {
if errdefs.IsNotFound(err) {
// An edge case is reported that Docker can list containers with no names,
// but when inspecting a container with specific ID and it is not found.
// In this case, we can safely ignore the error.
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
return
}
aggMu.Lock()
aggErr = errors.Join(aggErr, err)
processedCount++
aggMu.Unlock()
return
}
@@ -56,6 +68,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
stopped += stat.Stopped
healthy += stat.Healthy
unhealthy += stat.Unhealthy
processedCount++
mu.Unlock()
})
}
@@ -67,7 +80,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: len(containers),
Total: processedCount,
}, aggErr
}
+11 -13
View File
@@ -3,9 +3,11 @@ package stats
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -37,6 +39,7 @@ func TestCalculateContainerStats(t *testing.T) {
{ID: "container8"},
{ID: "container9"},
{ID: "container10"},
{ID: "container11"},
}
// Setup mock expectations with different container states to test various scenarios
@@ -58,7 +61,6 @@ func TestCalculateContainerStats(t *testing.T) {
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
}
expected := ContainerStats{}
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
for _, state := range containerStates {
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
@@ -68,15 +70,12 @@ func TestCalculateContainerStats(t *testing.T) {
Health: state.health,
},
},
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
expected.Running += state.expected.Running
expected.Stopped += state.expected.Stopped
expected.Healthy += state.expected.Healthy
expected.Unhealthy += state.expected.Unhealthy
expected.Total++
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
}
// Setup mock expectation for a container that returns NotFound error
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
// Call the function and measure time
startTime := time.Now()
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
@@ -84,11 +83,10 @@ func TestCalculateContainerStats(t *testing.T) {
duration := time.Since(startTime)
// Assert results
assert.Equal(t, expected, stats)
assert.Equal(t, expected.Running, stats.Running)
assert.Equal(t, expected.Stopped, stats.Stopped)
assert.Equal(t, expected.Healthy, stats.Healthy)
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
assert.Equal(t, 6, stats.Running)
assert.Equal(t, 4, stats.Stopped)
assert.Equal(t, 2, stats.Healthy)
assert.Equal(t, 2, stats.Unhealthy)
assert.Equal(t, 10, stats.Total)
// Verify concurrent processing by checking that all mock calls were made
+3
View File
@@ -77,6 +77,9 @@ type (
// CreatedByUserId is the user ID that created this stack
// Used for adding labels to Kubernetes manifests
CreatedByUserId string
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig portainer.HelmConfig
}
DeployerOptionsPayload struct {
+11 -15
View File
@@ -66,7 +66,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
@@ -97,7 +97,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
},
Remove: options.Remove,
Args: options.Args,
@@ -146,7 +146,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
Registries: portainerRegistriesToAuthConfigs(options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
@@ -229,7 +229,12 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
// portainerRegistriesToAuthConfigs converts registries to Docker auth configs.
// Callers must ensure ECR tokens are valid before calling this function (e.g. via
// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function
// intentionally performs no DB writes to avoid write-lock contention when called inside
// an active BoltDB write transaction.
func portainerRegistriesToAuthConfigs(registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
@@ -242,7 +247,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(&r)
if err != nil {
continue
}
@@ -254,16 +259,7 @@ func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) {
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
+72
View File
@@ -6,6 +6,7 @@ import (
"path"
"path/filepath"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
@@ -94,3 +95,74 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
}
func Test_portainerRegistriesToAuthConfigs(t *testing.T) {
t.Parallel()
t.Run("returns empty slice for empty input", func(t *testing.T) {
t.Parallel()
result := portainerRegistriesToAuthConfigs([]portainer.Registry{})
require.Nil(t, result)
})
t.Run("uses registry URL, username and password for non-authenticated registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: false},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "registry.example.com", result[0].ServerAddress)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("uses username and password for authenticated non-ECR registry", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "registry.example.com", Username: "user", Password: "pass", Authentication: true, Type: portainer.CustomRegistry},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "user", result[0].Username)
require.Equal(t, "pass", result[0].Password)
})
t.Run("parses ECR access token for authenticated ECR registry with valid token", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "secretkey",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "AWS:ecr-password",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "AWS", result[0].Username)
require.Equal(t, "ecr-password", result[0].Password)
})
t.Run("includes valid registries and skips ones with credential errors", func(t *testing.T) {
t.Parallel()
registries := []portainer.Registry{
{URL: "valid.example.com", Username: "user", Password: "pass", Authentication: false},
{
URL: "123456789.dkr.ecr.us-east-1.amazonaws.com",
Authentication: true,
Type: portainer.EcrRegistry,
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: "no-colon-token",
AccessTokenExpiry: time.Now().Add(time.Hour).Unix(),
},
}
result := portainerRegistriesToAuthConfigs(registries)
require.Len(t, result, 1)
require.Equal(t, "valid.example.com", result[0].ServerAddress)
})
}
+1 -1
View File
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
operations := map[string]func(context.Context, []string) (string, error){
"apply": client.ApplyDynamic,
"delete": client.Delete,
"delete": client.DeleteDynamic,
}
operationFunc, ok := operations[operation]
+1 -1
View File
@@ -61,7 +61,7 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
for _, registry := range registries {
if registry.Authentication {
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
username, password, err := getEffectiveRegUsernamePassword(&registry)
if err != nil {
continue
}
+51 -20
View File
@@ -3,23 +3,42 @@ package git
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/rs/zerolog/log"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
// symlink traversal attacks from untrusted git repositories
type noSymlinkFS struct {
billy.Filesystem
}
func (fs noSymlinkFS) Symlink(_, _ string) error {
return gittypes.ErrSymlinkDetected
}
// NewNoSymlinkFS wraps fs and rejects any symlink creation
func NewNoSymlinkFS(fs billy.Filesystem) billy.Filesystem {
return noSymlinkFS{fs}
}
type gitClient struct {
preserveGitDirectory bool
}
@@ -30,8 +49,33 @@ func NewGitClient(preserveGitDir bool) *gitClient {
}
}
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
wt := NewNoSymlinkFS(osfs.New(dst))
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
_, err := git.CloneContext(ctx, storer, wt, opt)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if c.preserveGitDirectory {
return nil
}
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
return nil
}
func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) error {
gitOptions := git.CloneOptions{
gitOptions := &git.CloneOptions{
URL: opt.repositoryUrl,
Depth: opt.depth,
InsecureSkipTLS: opt.tlsSkipVerify,
@@ -43,23 +87,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
}
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
if err != nil {
if err.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return errors.Wrap(err, "failed to clone git repository")
}
if !c.preserveGitDirectory {
err := os.RemoveAll(filepath.Join(dst, ".git"))
if err != nil {
log.Error().Err(err).Msg("failed to remove .git directory")
}
}
return nil
return c.Download(ctx, dst, gitOptions)
}
func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string, error) {
@@ -78,6 +106,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string
if err.Error() == "authentication required" {
return "", gittypes.ErrAuthenticationFailure
}
return "", errors.Wrap(err, "failed to list repository refs")
}
@@ -159,6 +188,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err
if ref.Name().String() == "HEAD" {
continue
}
ret = append(ret, ref.Name().String())
}
@@ -225,5 +255,6 @@ func checkGitError(err error) error {
} else if errMsg == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
return err
}
+113 -3
View File
@@ -3,13 +3,17 @@ package git
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/portainer/portainer/api/archive"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
@@ -18,7 +22,7 @@ import (
func setup(t *testing.T) string {
dir := t.TempDir()
bareRepoDir := filepath.Join(dir, "test-clone.git")
bareRepoDir := filesystem.JoinPaths(dir, "test-clone.git")
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
if err != nil {
@@ -53,7 +57,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
t.Logf("Cloning into %s", dir)
err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false)
require.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git"))
assert.NoDirExists(t, filesystem.JoinPaths(dir, ".git"))
}
func Test_cloneRepository(t *testing.T) {
@@ -146,6 +150,112 @@ func getCommitHistoryLength(t *testing.T, dir string) int {
return count
}
func Test_noSymlinkFS_Symlink(t *testing.T) {
fs := NewNoSymlinkFS(osfs.New(t.TempDir()))
err := fs.Symlink("../../../etc/passwd", "evil-link")
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_noSymlinkFS_OtherOperations(t *testing.T) {
dir := t.TempDir()
fs := NewNoSymlinkFS(osfs.New(dir))
f, err := fs.Create("test.txt")
require.NoError(t, err)
_, err = f.Write([]byte("hello"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
info, err := fs.Stat("test.txt")
require.NoError(t, err)
require.Equal(t, "test.txt", info.Name())
}
func createBareRepoWithSymlink(t *testing.T) string {
t.Helper()
bareDir := filesystem.JoinPaths(t.TempDir(), "symlink-repo.git")
repo, err := git.PlainInit(bareDir, true)
require.NoError(t, err)
storer := repo.Storer
fileBlob := &plumbing.MemoryObject{}
fileBlob.SetType(plumbing.BlobObject)
_, err = fileBlob.Write([]byte("hello world\n"))
require.NoError(t, err)
fileHash, err := storer.SetEncodedObject(fileBlob)
require.NoError(t, err)
symlinkBlob := &plumbing.MemoryObject{}
symlinkBlob.SetType(plumbing.BlobObject)
_, err = symlinkBlob.Write([]byte("../../../etc/passwd"))
require.NoError(t, err)
symlinkHash, err := storer.SetEncodedObject(symlinkBlob)
require.NoError(t, err)
tree := &object.Tree{
Entries: []object.TreeEntry{
{Name: "evil-link", Mode: filemode.Symlink, Hash: symlinkHash},
{Name: "file.txt", Mode: filemode.Regular, Hash: fileHash},
},
}
treeObj := &plumbing.MemoryObject{}
err = tree.Encode(treeObj)
require.NoError(t, err)
treeHash, err := storer.SetEncodedObject(treeObj)
require.NoError(t, err)
sig := object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}
commit := &object.Commit{
Message: "add symlink",
Author: sig,
Committer: sig,
TreeHash: treeHash,
}
commitObj := &plumbing.MemoryObject{}
err = commit.Encode(commitObj)
require.NoError(t, err)
commitHash, err := storer.SetEncodedObject(commitObj)
require.NoError(t, err)
err = storer.SetReference(plumbing.NewHashReference("refs/heads/main", commitHash))
require.NoError(t, err)
err = storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main"))
require.NoError(t, err)
return bareDir
}
func Test_Download_RejectsSymlink(t *testing.T) {
client := NewGitClient(false)
repoURL := createBareRepoWithSymlink(t)
err := client.Download(t.Context(), t.TempDir(), &git.CloneOptions{
URL: repoURL,
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
})
require.Error(t, err)
require.ErrorIs(t, err, gittypes.ErrSymlinkDetected)
}
func Test_listRefsPrivateRepository(t *testing.T) {
ensureIntegrationTest(t)
+1
View File
@@ -7,6 +7,7 @@ import (
var (
ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct")
ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct")
ErrSymlinkDetected = errors.New("repository contains a symlink, which is not allowed for security reasons")
)
type GitCredentialAuthType int
+4 -3
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"errors"
"net/http"
@@ -25,7 +26,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
if code == "" {
return "", errors.New("Invalid OAuth authorization code")
}
@@ -34,7 +35,7 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
return "", errors.New("Invalid OAuth configuration")
}
username, err := handler.OAuthService.Authenticate(code, settings)
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
if err != nil {
return "", err
}
@@ -70,7 +71,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled"))
}
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
if err != nil {
log.Debug().Err(err).Msg("OAuth authentication error")
@@ -2,8 +2,14 @@ package customtemplates
import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -33,11 +39,46 @@ func (handler *Handler) customTemplateFile(w http.ResponseWriter, r *http.Reques
return httperror.BadRequest("Invalid custom template identifier route variable", err)
}
customTemplate, err := handler.DataStore.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
var customTemplate *portainer.CustomTemplate
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
}
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(strconv.Itoa(customTemplateID), portainer.CustomTemplateResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the custom template", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
canEdit := userCanEditTemplate(customTemplate, securityContext)
hasAccess := false
if resourceControl != nil {
customTemplate.ResourceControl = resourceControl
teamIDs := slicesx.Map(securityContext.UserMemberships, func(m portainer.TeamMembership) portainer.TeamID {
return m.TeamID
})
hasAccess = authorization.UserCanAccessResource(securityContext.UserID, teamIDs, resourceControl)
}
if canEdit || hasAccess {
return nil
}
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}); err != nil {
return response.TxErrorResponse(err)
}
entryPath := customTemplate.EntryPoint
@@ -0,0 +1,115 @@
package customtemplates
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestCustomTemplateFile(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
templateContent := "some template content"
templateEntrypoint := "entrypoint"
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 3, Username: "std3", Role: portainer.StandardUserRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 4, Username: "std4", Role: portainer.StandardUserRole}))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
2: portainer.AccessPolicy{RoleID: 0},
3: portainer.AccessPolicy{RoleID: 0},
}}))
require.NoError(t, tx.Team().Create(&portainer.Team{ID: 1}))
require.NoError(t, tx.TeamMembership().Create(&portainer.TeamMembership{ID: 1, UserID: 3, TeamID: 1, Role: portainer.TeamMember}))
// template 1
path, err := fs.StoreCustomTemplateFileFromBytes("1", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 1, EntryPoint: templateEntrypoint, ProjectPath: path}))
// template 2
path, err = fs.StoreCustomTemplateFileFromBytes("2", templateEntrypoint, []byte(templateContent))
require.NoError(t, err)
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ID: 2, EntryPoint: templateEntrypoint, ProjectPath: path}))
require.NoError(t, tx.ResourceControl().Create(&portainer.ResourceControl{ID: 1, ResourceID: "2", Type: portainer.CustomTemplateResourceControl,
UserAccesses: []portainer.UserResourceAccess{{UserID: 2}},
TeamAccesses: []portainer.TeamResourceAccess{{TeamID: 1}},
}))
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID+"/file", nil)
r = mux.SetURLVars(r, map[string]string{"id": templateID})
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
return rr, handler.customTemplateFile(rr, r)
}
t.Run("unknown id should get not found error", func(t *testing.T) {
_, r := test("0", &security.RestrictedRequestContext{UserID: 1})
require.NotNil(t, r)
require.Equal(t, http.StatusNotFound, r.StatusCode)
})
t.Run("admin should access adminonly template", func(t *testing.T) {
rr, r := test("1", &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access adminonly template", func(t *testing.T) {
_, r := test("1", &security.RestrictedRequestContext{UserID: 2})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
t.Run("std should access template via direct user access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 2})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should access template via team access", func(t *testing.T) {
rr, r := test("2", &security.RestrictedRequestContext{UserID: 3, UserMemberships: []portainer.TeamMembership{{ID: 1, UserID: 3, TeamID: 1}}})
require.Nil(t, r)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var res struct{ FileContent string }
require.NoError(t, json.NewDecoder(rr.Body).Decode(&res))
require.Equal(t, templateContent, res.FileContent)
})
t.Run("std should not access template without access", func(t *testing.T) {
_, r := test("2", &security.RestrictedRequestContext{UserID: 4})
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
})
}
@@ -38,7 +38,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
var customTemplate *portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
customTemplate, err = tx.CustomTemplate().Read(portainer.CustomTemplateID(customTemplateID))
if handler.DataStore.IsErrObjectNotFound(err) {
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a custom template with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a custom template with the specified identifier inside the database", err)
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -20,6 +21,9 @@ func TestInspectHandler(t *testing.T) {
_, ds := datastore.MustNewTestStore(t, true, false)
require.NotNil(t, ds)
fs, err := filesystem.NewService(t.TempDir(), t.TempDir())
require.NoError(t, err)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 2, Username: "std2", Role: portainer.StandardUserRole}))
@@ -42,7 +46,7 @@ func TestInspectHandler(t *testing.T) {
return nil
}))
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, &TestFileService{}, nil)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), ds, fs, nil)
test := func(templateID string, restrictedContext *security.RestrictedRequestContext) (*httptest.ResponseRecorder, *httperror.HandlerError) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/"+templateID, nil)
@@ -1,6 +1,7 @@
package containers
import (
"context"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -46,7 +47,7 @@ func (handler *Handler) recreate(w http.ResponseWriter, r *http.Request) *httper
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
newContainer, err := handler.containerService.Recreate(r.Context(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
newContainer, err := handler.containerService.Recreate(context.TODO(), endpoint, containerID, payload.PullImage, "", agentTargetHeader)
if err != nil {
return httperror.InternalServerError("Error recreating container", err)
}
+7 -1
View File
@@ -19,6 +19,7 @@ type StackViewModel struct {
Name string
IsExternal bool
Type portainer.StackType
Labels map[string]string
}
// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
@@ -56,6 +57,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name,
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: container.Labels,
}
}
}
@@ -68,6 +70,7 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
Name: name,
IsExternal: true,
Type: portainer.DockerSwarmStack,
Labels: service.Spec.Labels,
}
}
}
@@ -79,7 +82,10 @@ func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.Rest
return uac.FilterByResourceControl(stacksList, user, securityContext.UserMemberships,
func(item StackViewModel) (*portainer.ResourceControl, error) {
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
if item.InternalStack != nil {
return uac.StackResourceControlGetter(tx, environmentID)(*item.InternalStack)
}
return uac.ExternalStackResourceControlGetter(tx, environmentID)(uac.ExternalStack{Labels: item.Labels})
},
)
}
@@ -8,7 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
dockerconsts "github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
@@ -28,12 +28,13 @@ func TestHandler_getDockerStacks(t *testing.T) {
containers := []types.Container{
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack1",
consts.ComposeStackNameLabel: "stack1",
},
},
{
Labels: map[string]string{
dockerconsts.ComposeStackNameLabel: "stack2",
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
}
@@ -43,7 +44,7 @@ func TestHandler_getDockerStacks(t *testing.T) {
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{
Labels: map[string]string{
dockerconsts.SwarmStackNameLabel: "stack3",
consts.SwarmStackNameLabel: "stack3",
},
},
},
@@ -65,14 +66,16 @@ func TestHandler_getDockerStacks(t *testing.T) {
is.NoError(tx.Stack().Create(&stack1))
is.NoError(tx.Stack().Create(&portainer.Stack{
ID: 2,
Name: "stack2",
Name: "stack2", // stack 2 on env 2
EndpointID: 2,
Type: portainer.DockerSwarmStack,
}))
is.NoError(tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
is.NoError(tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole}))
return nil
}))
// testing admin user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: true,
@@ -93,11 +96,43 @@ func TestHandler_getDockerStacks(t *testing.T) {
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
{
Name: "stack3",
IsExternal: true,
Type: portainer.DockerSwarmStack,
Labels: map[string]string{
consts.SwarmStackNameLabel: "stack3",
},
},
}
assert.ElementsMatch(t, expectedStacks, stacksList)
return nil
}))
// testing standard user
is.NoError(store.ViewTx(func(tx dataservices.DataStoreTx) error {
stacksList, err := GetDockerStacks(tx, &security.RestrictedRequestContext{
IsAdmin: false,
UserID: 2,
}, environment.ID, containers, services)
require.NoError(t, err)
assert.Len(t, stacksList, 1)
expectedStacks := []StackViewModel{
{
Name: "stack2",
IsExternal: true,
Type: portainer.DockerComposeStack,
Labels: map[string]string{
consts.ComposeStackNameLabel: "stack2",
"io.portainer.accesscontrol.public": "true",
},
},
}
@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -38,9 +39,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
}
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
_, err := tx.EdgeGroup().Read(ID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
ok, err := tx.EdgeGroup().Exists(ID)
if !ok {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
} else if err != nil {
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
}
+4 -1
View File
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -147,7 +148,9 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
if len(payload.EdgeGroups) > 0 {
for _, edgeGroupID := range payload.EdgeGroups {
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
return dserrors.ErrObjectNotFound
} else if err != nil {
return err
}
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -42,9 +43,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
}
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
_, err := tx.EndpointGroup().Read(endpointGroupID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
if !ok {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
// Environment(Endpoint) group name
Name string `example:"my-environment-group"`
// Environment(Endpoint) group description
Description string `example:"description"`
Description *string `example:"description"`
// List of environment(endpoint) identifiers that will be part of this group
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
// List of tag identifiers associated to the environment(endpoint) group
TagIDs []portainer.TagID `example:"3,4"`
UserAccessPolicies portainer.UserAccessPolicies
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
endpointGroup.Name = payload.Name
}
if payload.Description != "" {
endpointGroup.Description = payload.Description
if payload.Description != nil {
endpointGroup.Description = *payload.Description
}
tagsChanged := false
@@ -147,11 +149,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
// Update flag with endpoint and continue
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
}
}(endpoint.ID, endpointGroup.ID)
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
}
}
}
}
@@ -161,7 +161,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
}
if tagsChanged {
// Handle associated endpoints updates
endpointsChanged := false
if payload.AssociatedEndpoints != nil {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
// Build a set of the new endpoint IDs for quick lookup
newEndpointSet := make(map[portainer.EndpointID]bool)
for _, id := range payload.AssociatedEndpoints {
newEndpointSet[id] = true
}
for i := range endpoints {
endpoint := &endpoints[i]
wasInGroup := endpoint.GroupID == endpointGroup.ID
shouldBeInGroup := newEndpointSet[endpoint.ID]
if wasInGroup && !shouldBeInGroup {
// Remove from group (move to Unassigned)
endpoint.GroupID = portainer.EndpointGroupID(1)
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
} else if !wasInGroup && shouldBeInGroup {
// Add to group
endpoint.GroupID = endpointGroup.ID
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
}
}
}
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
if tagsChanged && !endpointsChanged {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
log.Warn().Err(err).Msg("Unable to update user authorizations")
}
}
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
}
@@ -179,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
err = tx.Tag().Update(tagID, tag)
}
if handler.DataStore.IsErrObjectNotFound(err) {
if tx.IsErrObjectNotFound(err) {
log.Warn().Err(err).Msg("Unable to find tag inside the database")
} else if err != nil {
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
@@ -227,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
if endpointutils.IsEdgeEndpoint(endpoint) {
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
edgeJobs, err := tx.EdgeJob().ReadAll()
if err != nil {
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
}
@@ -39,6 +39,7 @@ const (
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
// @param endpointIds query []int false "will return only these environments(endpoints)"
// @param excludeIds query []int false "will exclude these environments(endpoints)"
// @param excludeGroupIds query []int false "will exclude environments(endpoints) belonging to these endpoint groups"
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
// @param agentVersions query []string false "will return only environments with on of these agent versions"
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
@@ -265,7 +265,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil {
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
}
}
+13
View File
@@ -38,6 +38,7 @@ type EnvironmentsQuery struct {
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
excludeGroupIds []portainer.EndpointGroupID
edgeGroupIds []portainer.EdgeGroupID
excludeEdgeGroupIds []portainer.EdgeGroupID
}
@@ -80,6 +81,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
excludeGroupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "excludeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
}
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
if err != nil {
return EnvironmentsQuery{}, err
@@ -119,6 +125,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
excludeGroupIds: excludeGroupIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
@@ -157,6 +164,12 @@ func (handler *Handler) filterEndpointsByQuery(
})
}
if len(query.excludeGroupIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeGroupIds, endpoint.GroupID)
})
}
if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
+40
View File
@@ -151,6 +151,46 @@ func Test_Filter_excludeIDs(t *testing.T) {
runTests(tests, t, handler, environments)
}
func Test_Filter_excludeGroupIDs(t *testing.T) {
groupA := portainer.EndpointGroupID(10)
groupB := portainer.EndpointGroupID(20)
groupC := portainer.EndpointGroupID(30)
endpoints := []portainer.Endpoint{
{ID: 1, GroupID: groupA, Type: portainer.DockerEnvironment},
{ID: 2, GroupID: groupA, Type: portainer.DockerEnvironment},
{ID: 3, GroupID: groupB, Type: portainer.DockerEnvironment},
{ID: 4, GroupID: groupB, Type: portainer.DockerEnvironment},
{ID: 5, GroupID: groupC, Type: portainer.DockerEnvironment},
}
handler := setupFilterTest(t, endpoints)
tests := []filterTest{
{
title: "should exclude endpoints in groupA",
expected: []portainer.EndpointID{3, 4, 5},
query: EnvironmentsQuery{
excludeGroupIds: []portainer.EndpointGroupID{groupA},
},
},
{
title: "should exclude endpoints in groupA and groupB",
expected: []portainer.EndpointID{5},
query: EnvironmentsQuery{
excludeGroupIds: []portainer.EndpointGroupID{groupA, groupB},
},
},
{
title: "should return all endpoints when excludeGroupIds is empty",
expected: []portainer.EndpointID{1, 2, 3, 4, 5},
query: EnvironmentsQuery{},
},
}
runTests(tests, t, handler, endpoints)
}
func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) {
n := 10000
+1 -1
View File
@@ -61,7 +61,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/agent_versions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
h.Handle("/endpoints/relations", bouncer.RestrictedAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.38.0
// @version 2.39.2
// @description.markdown api-description.md
// @termsOfService
+2 -1
View File
@@ -113,7 +113,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/system", bouncer.AdminAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresses/{ingress}", httperror.LoggerHandler(h.getKubernetesIngress)).Methods(http.MethodGet)
@@ -177,6 +177,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
return
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
+217 -209
View File
@@ -2,8 +2,10 @@ package kubernetes
import (
"net/http"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
@@ -31,33 +33,23 @@ import (
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
// @router /kubernetes/{id}/ingresscontrollers [get]
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
}
// Get endpoint from context (may have policies applied in-memory)
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
return httperror.InternalServerError(err.Error(), err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
return httperror.InternalServerError("Unable to get privileged kube client", err)
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
controllers, err := cli.GetIngressControllers()
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
}
// Add none controller if "AllowNone" is set for endpoint.
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
Type: "custom",
})
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
if controllers[i].ClassName != "none" {
controllers[i].New = true
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
var updatedClasses []portainer.KubernetesIngressClassConfig
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses = []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
if controllers[i].ClassName != "none" {
controllers[i].New = true
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
}
controllers = allowedControllers
}
return response.JSON(w, controllers)
}
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.InternalServerError(err.Error(), err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
return httperror.BadRequest("Unable to retrieve namespace from request", err)
}
currentControllers, err := cli.GetIngressControllers()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
}
// Add none controller if "AllowNone" is set for endpoint.
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
currentControllers = append(currentControllers, models.K8sIngressController{
Name: "none",
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
})
}
kubernetesConfig := endpoint.Kubernetes.Configuration
existingClasses := kubernetesConfig.IngressClasses
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
updatedClasses := []portainer.KubernetesIngressClassConfig{}
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
controllers := models.K8sIngressControllers{}
for i := range currentControllers {
globallyblocked := false
currentControllers[i].Availability = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: currentControllers[i].ClassName,
Type: currentControllers[i].Type,
}
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
// Check if the controller is blocked globally or in the current
// namespace.
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
continue
for i := range currentControllers {
globallyblocked := false
currentControllers[i].Availability = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
}
currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
globallyblocked = existingClass.GloballyBlocked
updatedClass := portainer.KubernetesIngressClassConfig{
Name: currentControllers[i].ClassName,
Type: currentControllers[i].Type,
}
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
// Check if the controller is blocked globally or in the current
// namespace.
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
continue
}
currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
globallyblocked = existingClass.GloballyBlocked
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
}
}
}
}
if !globallyblocked {
controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
if !globallyblocked {
controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
// @failure 500 "Server error occurred while attempting to update ingress controllers."
// @router /kubernetes/{id}/ingresscontrollers [put]
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
return httperror.BadRequest("Unable to retrieve environment", err)
}
payload := models.K8sIngressControllers{}
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
return httperror.InternalServerError("Unable to get privileged kube client", err)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
controllers, err := cli.GetIngressControllers()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
}
// Add none controller if "AllowNone" is set for endpoint.
// Use policy-applied endpoint for this check since it affects the response.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
})
}
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
controllers[i].New = true
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
// Now set new payload data
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
controllers[i].Availability = true
controllers[i].New = true
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
for i := range controllers {
// Now set new payload data
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
}
}
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
}
return response.Empty(w)
}
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.NotFound("Unable to fetch endpoint", err)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
return httperror.BadRequest("Unable to decode and validate the request payload", err)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
PayloadLoop:
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
continue
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: existingClass.Name,
Type: existingClass.Type,
GloballyBlocked: existingClass.GloballyBlocked,
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.InternalServerError("Unable to fetch endpoint", err)
}
// Handle "allow"
if p.Availability {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
continue
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: existingClass.Name,
Type: existingClass.Type,
GloballyBlocked: existingClass.GloballyBlocked,
}
// Handle "allow"
if p.Availability {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
}
}
}
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
}
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
for _, ns := range updatedClass.BlockedNamespaces {
if namespace == ns {
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
break
}
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
}
updatedClasses = append(updatedClasses, updatedClass)
break
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
found := false
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
break
}
}
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
updatedClasses = append(updatedClasses, updatedClass)
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
found := false
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
}
return response.Empty(w)
}
+38 -32
View File
@@ -5,6 +5,7 @@ import (
"net/http"
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/registryutils"
@@ -51,47 +52,52 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError("Unable to remove the registry from the database", err)
}
handler.deleteKubernetesSecrets(registry)
handler.deleteKubernetesSecrets(handler.DataStore, registry)
return response.Empty(w)
}
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
for endpointId, access := range registry.RegistryAccesses {
if access.Namespaces != nil {
// Obtain a kubeclient for the endpoint
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
if err != nil {
// Skip environments that can't be loaded from the DB
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
if access.Namespaces == nil {
continue
}
continue
// Obtain a kubeclient for the endpoint
endpoint, err := tx.Endpoint().Endpoint(endpointId)
if err != nil {
// Skip environments that can't be loaded from the DB
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
continue
}
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
// Skip environments that can't get a kubeclient from
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
continue
}
failedNamespaces := make([]string, 0)
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
}
}
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
// Skip environments that can't get a kubeclient from
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
if len(failedNamespaces) == 0 {
continue
}
continue
}
failedNamespaces := make([]string, 0)
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
}
}
if len(failedNamespaces) > 0 {
if err := handler.PendingActionsService.Create(
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
}
}
if err := handler.PendingActionsService.Create(
tx,
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
}
}
}
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
if err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
}
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
// Make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
} else if !isUnique {
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
+5 -10
View File
@@ -192,28 +192,23 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
// @router /stacks/create/swarm/repository [post]
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
var payload swarmStackFromGitRepositoryPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
if err != nil {
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
return httperror.InternalServerError("Unable to check for name collision", err)
}
if !isUnique {
} else if !isUnique {
return stackExistsError(payload.Name)
}
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
}
if !isUnique {
} else if !isUnique {
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
}
}
+3 -3
View File
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
return isUniqueStackName, nil
}
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
if handler.DataStore.IsErrObjectNotFound(err) {
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
_, err := tx.Stack().StackByWebhookID(webhookID)
if tx.IsErrObjectNotFound(err) {
return true, nil
}
return false, err
+1 -1
View File
@@ -76,7 +76,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
}
}
-1
View File
@@ -28,7 +28,6 @@ import (
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
-1
View File
@@ -32,7 +32,6 @@ type execStartOperationPayload struct {
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param nodeName query string false "node name"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 409
-1
View File
@@ -31,7 +31,6 @@ import (
// @param podName query string true "name of the pod containing the container"
// @param containerName query string true "name of the container"
// @param command query string true "command to execute in the container"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200
// @failure 400
// @failure 403
-1
View File
@@ -18,7 +18,6 @@ import (
// @accept json
// @produce json
// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located"
// @param token query string true "JWT token used for authentication against this environment(endpoint)"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
+63 -8
View File
@@ -170,18 +170,23 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
type PartialContainer struct {
HostConfig struct {
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Privileged bool `json:"Privileged"`
PidMode string `json:"PidMode"`
Devices []any `json:"Devices"`
Sysctls map[string]any `json:"Sysctls"`
SecurityOpt []string `json:"SecurityOpt"`
CapAdd []string `json:"CapAdd"`
CapDrop []string `json:"CapDrop"`
Binds []string `json:"Binds"`
Mounts []struct {
Type string `json:"Type"`
} `json:"Mounts"`
} `json:"HostConfig"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
Body: http.NoBody,
}
tokenData, err := security.RetrieveTokenData(request)
@@ -230,7 +235,7 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return nil, ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialContainer.HostConfig.Binds) > 0) {
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Binds) > 0 {
for _, bind := range partialContainer.HostConfig.Binds {
if strings.HasPrefix(bind, "/") {
return forbiddenResponse, ErrBindMountsForbidden
@@ -238,6 +243,14 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
}
}
if !securitySettings.AllowBindMountsForRegularUsers && len(partialContainer.HostConfig.Mounts) > 0 {
for _, mount := range partialContainer.HostConfig.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, ErrBindMountsForbidden
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
}
@@ -252,3 +265,45 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
return response, err
}
func (transport *Transport) decorateContainerUpdateOperation(request *http.Request, containerID string) (*http.Response, error) {
type PartialContainerUpdate struct {
Devices []any `json:"Devices"`
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if isAdminOrEndpointAdmin {
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, err
}
partialUpdate := &PartialContainerUpdate{}
if err := json.Unmarshal(body, partialUpdate); err != nil {
return nil, err
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(partialUpdate.Devices) > 0 {
return forbiddenResponse, ErrDeviceMappingForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
@@ -0,0 +1,123 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestDecorateContainerCreationOperation_BindMounts(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
regularUser := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
err := ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&admin)
require.NoError(t, err)
err = tx.User().Create(&regularUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Name: "test",
SecuritySettings: portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
},
})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/containers/create"}: map[string]any{"Id": "abc123", "Warnings": []any{}},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{ID: 1, URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
userToken := portainer.TokenData{ID: regularUser.ID, Username: regularUser.Username, Role: regularUser.Role}
makeRequest := func(token portainer.TokenData, body any) *http.Request {
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, srv.URL+"/v"+version+"/containers/create", bytes.NewReader(bodyBytes))
req = req.WithContext(security.StoreTokenData(req, &token))
return req
}
// Admin bypasses security checks
req := makeRequest(adminToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err := transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Binds with an absolute path is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Binds": []string{"/:/host:ro"},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with type bind is blocked for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "bind", "Source": "/", "Target": "/host"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
// HostConfig.Mounts with a non-bind type is allowed for regular users
req = makeRequest(userToken, map[string]any{
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "volume", "Source": "myvolume", "Target": "/data"}},
},
})
resp, err = transport.decorateContainerCreationOperation(req, containerObjectIdentifier, portainer.ContainerResourceControl)
require.NoError(t, err)
require.NotNil(t, resp)
err = resp.Body.Close()
require.NoError(t, err)
}
+96 -26
View File
@@ -3,13 +3,13 @@ package docker
import (
"bytes"
"context"
"errors"
"io"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
@@ -18,6 +18,70 @@ import (
const serviceObjectIdentifier = "ID"
type partialServiceSpec struct {
TaskTemplate struct {
ContainerSpec struct {
CapabilityAdd []string `json:"CapabilityAdd"`
CapabilityDrop []string `json:"CapabilityDrop"`
Sysctls map[string]any `json:"Sysctls"`
Privileges *struct {
Seccomp *struct{ Mode string } `json:"Seccomp"`
AppArmor *struct{ Mode string } `json:"AppArmor"`
} `json:"Privileges"`
Mounts []struct {
Type string `json:"Type"`
VolumeOptions *struct {
DriverConfig *struct {
Options map[string]string `json:"Options"`
} `json:"DriverConfig"`
} `json:"VolumeOptions"`
} `json:"Mounts"`
} `json:"ContainerSpec"`
} `json:"TaskTemplate"`
}
func CheckServiceBodyRestrictions(request *http.Request, securitySettings *portainer.EndpointSecuritySettings) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
spec := &partialServiceSpec{}
if err := json.Unmarshal(body, spec); err != nil {
return err
}
containerSpec := spec.TaskTemplate.ContainerSpec
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(containerSpec.CapabilityAdd) > 0 || len(containerSpec.CapabilityDrop) > 0) {
return ErrContainerCapabilitiesForbidden
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(containerSpec.Sysctls) > 0 {
return ErrSysCtlSettingsForbidden
}
if !securitySettings.AllowBindMountsForRegularUsers {
for _, mount := range containerSpec.Mounts {
if mount.Type == "bind" {
return ErrBindMountsForbidden
}
if mount.VolumeOptions != nil && mount.VolumeOptions.DriverConfig != nil {
if mount.VolumeOptions.DriverConfig.Options["type"] == "bind" {
return ErrBindMountsForbidden
}
}
}
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{})
if err != nil {
@@ -90,20 +154,6 @@ func selectorServiceLabels(responseObject map[string]any) map[string]any {
}
func (transport *Transport) decorateServiceCreationOperation(request *http.Request) (*http.Response, error) {
type PartialService struct {
TaskTemplate struct {
ContainerSpec struct {
Mounts []struct {
Type string
}
}
}
}
forbiddenResponse := &http.Response{
StatusCode: http.StatusForbidden,
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
@@ -118,25 +168,45 @@ func (transport *Transport) decorateServiceCreationOperation(request *http.Reque
return nil, err
}
body, err := io.ReadAll(request.Body)
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create service with specified configuration")),
}, err
}
return transport.replaceRegistryAuthenticationHeader(request)
}
func (transport *Transport) decorateServiceUpdateOperation(request *http.Request, serviceID string) (*http.Response, error) {
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
partialService := &PartialService{}
if err := json.Unmarshal(body, partialService); err != nil {
if isAdminOrEndpointAdmin {
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.executeDockerRequest(request)
}
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers && (len(partialService.TaskTemplate.ContainerSpec.Mounts) > 0) {
for _, mount := range partialService.TaskTemplate.ContainerSpec.Mounts {
if mount.Type == "bind" {
return forbiddenResponse, errors.New("forbidden to use bind mounts")
}
}
if err := CheckServiceBodyRestrictions(request, securitySettings); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to update service with specified configuration")),
}, err
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
}
return transport.replaceRegistryAuthenticationHeader(request)
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
@@ -0,0 +1,522 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const serviceCreationAPIVersion = "1.51"
type serviceCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newServiceCreationFixtures(t *testing.T) *serviceCreationFixtures {
t.Helper()
const serviceID = "some-service-id"
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", serviceCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"ID": serviceID})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &serviceCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *serviceCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *serviceCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *serviceCreationFixtures) newRequest(t *testing.T, spec swarm.ServiceSpec, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(spec)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+serviceCreationAPIVersion+"/services/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
var (
restrictiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: false,
AllowSysctlSettingForRegularUsers: false,
AllowBindMountsForRegularUsers: false,
}
permissiveSettings = portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
}
)
func TestDecorateServiceCreationOperation_CapabilityAddForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilityDropForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityDrop: []string{"MKNOD"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_CapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NoCapabilitiesAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
var spec swarm.ServiceSpec
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_SysctlAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_NonBindMountNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: false,
})
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeVolume}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_BindMountAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_AdminBypassesAllSecurityChecks(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
CapabilityDrop: []string{"MKNOD"},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.adminUser))
require.NotErrorIs(t, err, ErrContainerCapabilitiesForbidden)
require.NotErrorIs(t, err, ErrSysCtlSettingsForbidden)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_StandardUserPermissiveSettingsSucceeds(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
CapabilityAdd: []string{"NET_ADMIN"},
Sysctls: map[string]string{"net.core.somaxconn": "128"},
Mounts: []mount.Mount{{Type: mount.TypeBind}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, http.StatusCreated, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithBindDriverOptionAllowed(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, permissiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceCreationOperation_VolumeWithNonBindDriverOptionNotForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "tmpfs"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceCreationOperation(f.newRequest(t, spec, f.stdUser))
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateServiceUpdateOperation_VolumeWithBindDriverOptionForbidden(t *testing.T) {
t.Parallel()
f := newServiceCreationFixtures(t)
f.setSecuritySettings(t, restrictiveSettings)
spec := swarm.ServiceSpec{
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
Mounts: []mount.Mount{{
Type: mount.TypeVolume,
VolumeOptions: &mount.VolumeOptions{
DriverConfig: &mount.Driver{
Options: map[string]string{"type": "bind", "device": "/etc"},
},
},
}},
},
},
}
resp, err := f.newTransport().decorateServiceUpdateOperation(f.newRequest(t, spec, f.stdUser), "test-service-id")
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+38 -1
View File
@@ -22,6 +22,7 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/slicesx"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
@@ -109,6 +110,28 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
"volumes": (*Transport).proxyVolumeRequest,
}
type route struct {
method string
pattern *regexp.Regexp
}
var adminOnlyRoutes = []route{
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/enable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/disable$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/pull$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/push$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/upgrade$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/.+/set$`)},
{http.MethodPost, regexp.MustCompile(`^/plugins/create$`)},
{http.MethodDelete, regexp.MustCompile(`^/plugins/.+$`)},
}
func isAdminOnlyRoute(method string, path string) bool {
return slicesx.Some(adminOnlyRoutes, func(r route) bool {
return method == r.method && r.pattern.MatchString(path)
})
}
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
@@ -137,6 +160,10 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res
return proxyFunc(transport, request, unversionedPath)
}
if isAdminOnlyRoute(request.Method, unversionedPath) {
return transport.administratorOperation(request)
}
return transport.executeDockerRequest(request)
}
@@ -261,6 +288,11 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi
if action == "json" {
return transport.rewriteOperation(request, transport.containerInspectOperation)
}
if action == "update" {
return transport.decorateContainerUpdateOperation(request, containerID)
}
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
@@ -292,6 +324,11 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
action := path.Base(requestPath)
if action == "update" {
return transport.decorateServiceUpdateOperation(request, serviceID)
}
if err := transport.decorateRegistryAuthenticationHeader(request); err != nil {
return nil, err
@@ -747,7 +784,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
responseObject = decorateObject(responseObject, resourceControl)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, response.StatusCode)
}
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
@@ -108,6 +108,141 @@ func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Serve
return srv, version
}
func TestTransport_adminProxy(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.User().Create(&std2))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
// allowed routes
{http.MethodGet, "/plugins"}: nil,
{http.MethodGet, "/plugins/xxx/json"}: nil,
{http.MethodGet, "/plugins/privileges"}: nil,
// admin routes ; see `adminOnlyRoutes`
{http.MethodDelete, "/plugins/xxx"}: nil,
{http.MethodPost, "/plugins/sshfs/enable"}: nil, // simulate plugin "sshfs"
{http.MethodPost, "/plugins/vieux/sshfs/enable"}: nil, // simulate "vieux/sshfs"
{http.MethodPost, "/plugins/xxx/disable"}: nil,
{http.MethodPost, "/plugins/pull"}: nil,
{http.MethodPost, "/plugins/xxx/push"}: nil,
{http.MethodPost, "/plugins/xxx/upgrade"}: nil,
{http.MethodPost, "/plugins/xxx/set"}: nil,
{http.MethodPost, "/plugins/create"}: nil,
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.ProxyDockerRequest(req)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role}
{
r, err := test(http.MethodGet, "/plugins", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodGet, "/plugins", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/pull", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
{
r, err := test(http.MethodPost, "/plugins/vieux/sshfs/enable", std2Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}
func TestTransport_getRealResourceID(t *testing.T) {
srv, _ := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}},
+49
View File
@@ -1,9 +1,11 @@
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
@@ -15,6 +17,7 @@ import (
"github.com/portainer/portainer/api/logs"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
)
const volumeObjectIdentifier = "ResourceID"
@@ -122,12 +125,58 @@ func selectorVolumeLabels(responseObject map[string]any) map[string]any {
return utils.GetJSONObject(responseObject, "Labels")
}
func CheckVolumeBodyRestrictions(request *http.Request) error {
defer logs.CloseAndLogErr(request.Body)
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
var volumeCreateBody struct {
DriverOpts map[string]string `json:"DriverOpts"`
}
if err := json.Unmarshal(body, &volumeCreateBody); err != nil {
return err
}
if volumeCreateBody.DriverOpts["type"] == "bind" {
return ErrBindMountsForbidden
}
request.Body = io.NopCloser(bytes.NewBuffer(body))
return nil
}
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
isAdminOrEndpointAdmin, err := transport.isAdminOrEndpointAdmin(request)
if err != nil {
return nil, err
}
if !isAdminOrEndpointAdmin {
securitySettings, err := transport.fetchEndpointSecuritySettings()
if err != nil {
return nil, err
}
if !securitySettings.AllowBindMountsForRegularUsers {
if err := CheckVolumeBodyRestrictions(request); err != nil {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(bytes.NewBufferString("Access denied: insufficient permissions to create volume with specified configuration")),
}, err
}
}
}
volumeID := request.Header.Get("X-Portainer-VolumeName")
if volumeID != "" {
@@ -0,0 +1,226 @@
package docker
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/docker/docker/api/types/volume"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
const volumeCreationAPIVersion = "1.51"
type volumeCreationFixtures struct {
dockerSrv *httptest.Server
ds dataservices.DataStore
stdUser portainer.User
adminUser portainer.User
endpointID portainer.EndpointID
}
func newVolumeCreationFixtures(t *testing.T) *volumeCreationFixtures {
t.Helper()
dockerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
w.Header().Add("Api-Version", volumeCreationAPIVersion)
_, _ = w.Write([]byte{})
return
}
if r.Method == http.MethodPost {
data, err := json.Marshal(map[string]string{"Name": "test-volume"})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}))
t.Cleanup(dockerSrv.Close)
_, store := datastore.MustNewTestStore(t, true, false)
f := &volumeCreationFixtures{
dockerSrv: dockerSrv,
ds: store,
stdUser: portainer.User{ID: 1, Username: "std", Role: portainer.StandardUserRole},
adminUser: portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole},
endpointID: portainer.EndpointID(1),
}
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
err := tx.User().Create(&f.stdUser)
require.NoError(t, err)
err = tx.User().Create(&f.adminUser)
require.NoError(t, err)
err = tx.Endpoint().Create(&portainer.Endpoint{ID: f.endpointID, Name: "test-env"})
require.NoError(t, err)
return nil
})
require.NoError(t, err)
return f
}
func (f *volumeCreationFixtures) setSecuritySettings(t *testing.T, settings portainer.EndpointSecuritySettings) {
t.Helper()
err := f.ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Endpoint().UpdateEndpoint(f.endpointID, &portainer.Endpoint{
ID: f.endpointID,
Name: "test-env",
SecuritySettings: settings,
})
})
require.NoError(t, err)
}
func (f *volumeCreationFixtures) newTransport() *Transport {
return &Transport{
endpoint: &portainer.Endpoint{ID: f.endpointID},
dataStore: f.ds,
HTTPTransport: &http.Transport{},
}
}
func (f *volumeCreationFixtures) newRequest(t *testing.T, body volume.CreateOptions, user portainer.User) *http.Request {
t.Helper()
data, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequestWithContext(
t.Context(),
http.MethodPost,
f.dockerSrv.URL+"/v"+volumeCreationAPIVersion+"/volumes/create",
bytes.NewReader(data),
)
require.NoError(t, err)
return req.WithContext(security.StoreTokenData(req, &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}))
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "evil-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
"o": "bind",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.ErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedForAdmin(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "admin-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/etc",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.adminUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_BindDriverOptAllowedWhenSettingPermissive(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: true,
})
body := volume.CreateOptions{
Name: "allowed-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "bind",
"device": "/data",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
func TestDecorateVolumeResourceCreationOperation_NonBindDriverOptNotForbidden(t *testing.T) {
t.Parallel()
f := newVolumeCreationFixtures(t)
f.setSecuritySettings(t, portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
})
body := volume.CreateOptions{
Name: "normal-volume",
Driver: "local",
DriverOpts: map[string]string{
"type": "tmpfs",
},
}
resp, err := f.newTransport().decorateVolumeResourceCreationOperation(f.newRequest(t, body, f.stdUser), portainer.VolumeResourceControl)
require.NotErrorIs(t, err, ErrBindMountsForbidden)
require.NotNil(t, resp)
require.NotEqual(t, http.StatusForbidden, resp.StatusCode)
err = resp.Body.Close()
require.NoError(t, err)
}
+2 -14
View File
@@ -447,26 +447,14 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
return tokenData, nil
}
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
// extractBearerToken extracts the Bearer token from the Authorization header and returns the token.
func extractBearerToken(r *http.Request) (string, bool) {
// Token might be set via the "token" query parameter.
// For example, in websocket requests
// For these cases, hide the token from the query
query := r.URL.Query()
token := query.Get("token")
if token != "" {
query.Del("token")
r.URL.RawQuery = query.Encode()
return token, true
}
tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 {
return "", false
}
token = tokens[0]
token := tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
return token, true
+5 -189
View File
@@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te
}
}
return service.UpdateUsersAuthorizationsTx(tx)
return nil
}
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
@@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us
return nil
}
// UpdateUserAuthorizations will update the authorizations for the provided userid
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
err := service.updateUserAuthorizations(tx, userID)
if err != nil {
return err
}
return nil
}
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations.
//
// Deprecated: This function previously populated the User.EndpointAuthorizations field which is
// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess.
func (service *Service) UpdateUsersAuthorizations() error {
return service.UpdateUsersAuthorizationsTx(service.dataStore)
}
func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error {
users, err := tx.User().ReadAll()
if err != nil {
return err
}
for _, user := range users {
err := service.updateUserAuthorizations(tx, user.ID)
if err != nil {
return err
}
}
return nil
}
func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
user, err := tx.User().Read(userID)
if err != nil {
return err
}
endpointAuthorizations, err := service.getAuthorizations(tx, user)
if err != nil {
return err
}
user.EndpointAuthorizations = endpointAuthorizations
return tx.User().Update(userID, user)
}
func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) {
endpointAuthorizations := portainer.EndpointAuthorizations{}
if user.Role == portainer.AdministratorRole {
return endpointAuthorizations, nil
}
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return endpointAuthorizations, err
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return endpointAuthorizations, err
}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return endpointAuthorizations, err
}
roles, err := tx.Role().ReadAll()
if err != nil {
return endpointAuthorizations, err
}
endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships)
return endpointAuthorizations, nil
}
func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations {
endpointAuthorizations := make(portainer.EndpointAuthorizations)
groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{}
groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{}
for _, endpointGroup := range endpointGroups {
groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies
groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies
}
for _, endpoint := range endpoints {
authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
continue
}
authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
continue
}
authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
continue
}
authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
}
}
return endpointAuthorizations
}
func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
policy, ok := endpoint.UserAccessPolicies[user.ID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
for _, membership := range memberships {
policy, ok := endpoint.TeamAccessPolicies[membership.TeamID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
for _, membership := range memberships {
policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations {
var associatedRoles []portainer.Role
for _, id := range roleIdentifiers {
for _, role := range roles {
if role.ID == id {
associatedRoles = append(associatedRoles, role)
break
}
}
}
var authorizations portainer.Authorizations
highestPriority := 0
for _, role := range associatedRoles {
if role.Priority > highestPriority {
highestPriority = role.Priority
authorizations = role.Authorizations
}
}
return authorizations
}
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
user, err := tx.User().Read(userID)
if err != nil {
+41 -6
View File
@@ -1,6 +1,8 @@
package registryutils
import (
"context"
"fmt"
"time"
portainer "github.com/portainer/portainer/api"
@@ -14,9 +16,12 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
}
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
func fetchRegToken(registry *portainer.Registry) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken(ctx)
if err != nil {
return err
}
@@ -24,12 +29,34 @@ func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) er
registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix()
return nil
}
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if err := fetchRegToken(registry); err != nil {
return err
}
return tx.Registry().Update(registry.ID, registry)
}
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
// ValidateRegistriesECRTokens refreshes and persists ECR tokens for all registries that need it.
// Must be called with a real DataStoreTx (not a top-level DataStore) to avoid write-lock contention.
func ValidateRegistriesECRTokens(tx dataservices.DataStoreTx, registries []portainer.Registry) error {
for i := range registries {
reg := &registries[i]
if reg.Type != portainer.EcrRegistry {
continue
}
if isRegTokenValid(reg) {
continue
}
if err := doGetRegToken(tx, reg); err != nil {
return fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", reg.Name, err)
}
}
return nil
}
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
@@ -57,7 +84,15 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
password = registry.Password
if registry.Type == portainer.EcrRegistry {
username, password, err = parseRegToken(registry)
// Fallback token refresh in case the upstream caller did not pre-validate the token.
if !isRegTokenValid(registry) {
if err := fetchRegToken(registry); err != nil {
return "", "", fmt.Errorf("ECR registry %q credentials are invalid or expired. Error: %w", registry.Name, err)
}
}
username, password, err = ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
}
return
@@ -0,0 +1,131 @@
package registryutils_test
import (
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/stretchr/testify/require"
)
func newECRRegistry(id portainer.RegistryID, accessToken string, expiry int64) portainer.Registry {
return portainer.Registry{
ID: id,
Type: portainer.EcrRegistry,
Name: "test-ecr",
Username: "AKIAIOSFODNN7EXAMPLE",
Password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
Ecr: portainer.EcrData{Region: "us-east-1"},
AccessToken: accessToken,
AccessTokenExpiry: expiry,
}
}
func TestValidateRegistriesECRTokens(t *testing.T) {
t.Parallel()
t.Run("skips non-ECR registries without error", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
registries := []portainer.Registry{
{ID: 1, Type: portainer.DockerHubRegistry, Name: "dockerhub"},
{ID: 2, Type: portainer.CustomRegistry, Name: "custom"},
}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, registries)
}))
})
t.Run("skips ECR registries with valid tokens", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
reg := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
}))
})
t.Run("returns nil for empty registry list", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{})
}))
})
t.Run("returns error for ECR registry with missing token", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
reg := newECRRegistry(1, "", 0)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Registry().Create(&reg)
}))
var validateErr error
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{reg})
return nil
})
require.Error(t, validateErr)
require.Contains(t, validateErr.Error(), "test-ecr")
})
t.Run("stops on first invalid ECR registry and includes its name in error", func(t *testing.T) {
t.Parallel()
_, ds := datastore.MustNewTestStore(t, true, false)
validECR := newECRRegistry(1, "valid-token", time.Now().Add(time.Hour).Unix())
invalidECR := newECRRegistry(2, "", 0)
invalidECR.Name = "invalid-ecr"
nonECR := portainer.Registry{ID: 3, Type: portainer.DockerHubRegistry}
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Registry().Create(&invalidECR)
}))
var validateErr error
_ = ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
validateErr = registryutils.ValidateRegistriesECRTokens(tx, []portainer.Registry{validECR, invalidECR, nonECR})
return nil
})
require.Error(t, validateErr)
require.Contains(t, validateErr.Error(), "invalid-ecr")
})
}
func TestGetRegEffectiveCredential(t *testing.T) {
t.Parallel()
t.Run("returns username and password directly for non-ECR registry", func(t *testing.T) {
t.Parallel()
reg := &portainer.Registry{
Type: portainer.DockerHubRegistry,
Username: "user",
Password: "pass",
}
username, password, err := registryutils.GetRegEffectiveCredential(reg)
require.NoError(t, err)
require.Equal(t, "user", username)
require.Equal(t, "pass", password)
})
t.Run("parses ECR access token when token is valid", func(t *testing.T) {
t.Parallel()
reg := newECRRegistry(1, "AWS:ecr-password", time.Now().Add(time.Hour).Unix())
username, password, err := registryutils.GetRegEffectiveCredential(&reg)
require.NoError(t, err)
require.Equal(t, "AWS", username)
require.Equal(t, "ecr-password", password)
})
t.Run("returns error for ECR registry with missing token and invalid credentials", func(t *testing.T) {
t.Parallel()
reg := newECRRegistry(1, "", 0)
_, _, err := registryutils.GetRegEffectiveCredential(&reg)
require.Error(t, err)
require.Contains(t, err.Error(), "test-ecr")
})
}
+21
View File
@@ -147,6 +147,27 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
}
}
type stubSSLSettingsService struct {
settings *portainer.SSLSettings
}
func (s *stubSSLSettingsService) BucketName() string { return "ssl" }
func (s *stubSSLSettingsService) Settings() (*portainer.SSLSettings, error) {
return s.settings, nil
}
func (s *stubSSLSettingsService) UpdateSettings(settings *portainer.SSLSettings) error {
s.settings = settings
return nil
}
func WithSSLSettingsService(settings *portainer.SSLSettings) datastoreOption {
return func(d *testDatastore) {
d.sslSettings = &stubSSLSettingsService{settings: settings}
}
}
type stubUserService struct {
dataservices.UserService
+1 -1
View File
@@ -64,7 +64,7 @@ func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, err
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
jobs, err := kcl.getCronJobExecutions(cronJob.Name, cronJob.Namespace, jobsList)
if err != nil {
return models.K8sCronJob{}
}
+62
View File
@@ -5,7 +5,10 @@ import (
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
@@ -64,3 +67,62 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Logf("Deleted Cron Jobs")
})
}
// TestGetCronJobExecutionsNamespaceFilter verifies that getCronJobExecutions only returns
// executions belonging to the CronJob's own namespace, even when same-named CronJobs
// exist across multiple namespaces.
func TestGetCronJobExecutionsNamespaceFilter(t *testing.T) {
backoffLimit := int32(3)
completions := int32(1)
makeJob := func(name, namespace, cronJobName string) batchv1.Job {
return batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
OwnerReferences: []metav1.OwnerReference{
{Kind: "CronJob", Name: cronJobName},
},
},
Spec: batchv1.JobSpec{
BackoffLimit: &backoffLimit,
Completions: &completions,
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "worker", Image: "busybox"}},
},
},
},
}
}
// Simulate the cross-namespace job list returned when fetchCronJobs is called with namespace=""
allJobs := &batchv1.JobList{
Items: []batchv1.Job{
makeJob("backup-prod-28001440", "ns-prod", "backup"),
makeJob("backup-test-28001441", "ns-test", "backup"),
},
}
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
isKubeAdmin: true,
}
t.Run("returns only executions from the matching namespace", func(t *testing.T) {
result, err := kcl.getCronJobExecutions("backup", "ns-prod", allJobs)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "ns-prod", result[0].Namespace)
assert.Equal(t, "backup-prod-28001440", result[0].Name)
})
t.Run("returns only executions from the other matching namespace", func(t *testing.T) {
result, err := kcl.getCronJobExecutions("backup", "ns-test", allJobs)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "ns-test", result[0].Namespace)
assert.Equal(t, "backup-test-28001441", result[0].Name)
})
}
+26 -10
View File
@@ -3,8 +3,10 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
@@ -55,29 +57,43 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true,
}, scheme.ParameterCodec)
streamOpts := remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
}
// Try WebSocket executor first, fall back to SPDY if it fails
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config,
"GET", // WebSocket uses GET for the upgrade request
req.URL().String(),
channelProtocolList...,
)
if err != nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- err
if err == nil {
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err == nil {
return
}
log.Warn().
Err(err).
Str("context", "StartExecProcess").
Msg("WebSocket exec failed, falling back to SPDY")
}
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
})
// Fall back to SPDY executor
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
return
}
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err != nil {
var exitError utilexec.ExitError
if !errors.As(err, &exitError) {
errChan <- errors.New("unable to start exec process")
errChan <- fmt.Errorf("unable to start exec process: %w", err)
}
}
}
+5 -1
View File
@@ -168,11 +168,15 @@ func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
// getCronJobExecutions returns the jobs for a given cronjob
// it returns the jobs for the cronjob
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, cronJobNamespace string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
maxItems := 5
results := make([]models.K8sJob, 0)
for _, job := range jobs.Items {
if job.Namespace != cronJobNamespace {
continue
}
for _, owner := range job.OwnerReferences {
if owner.Kind == "CronJob" && owner.Name == cronJobName {
results = append(results, kcl.parseJob(job))
-1
View File
@@ -74,7 +74,6 @@ func Test_GenerateYAML(t *testing.T) {
name: portainer-ctx
current-context: portainer-ctx
kind: Config
preferences: {}
users:
- name: test-user
user:
+5 -5
View File
@@ -73,7 +73,7 @@ func (Service) AuthenticateUser(username, password string, settings *portainer.L
if err != nil {
return err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -108,7 +108,7 @@ func (Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
if err != nil {
return nil, err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -133,7 +133,7 @@ func (Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
if err != nil {
return nil, err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -182,7 +182,7 @@ func (Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPU
if err != nil {
return nil, err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
@@ -309,7 +309,7 @@ func (Service) TestConnectivity(settings *portainer.LDAPSettings) error {
if err != nil {
return err
}
defer connection.Close()
defer func() { _ = connection.Close() }()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
+2 -2
View File
@@ -33,7 +33,7 @@ func TestCreateConnectionForURL(t *testing.T) {
conn, err := createConnectionForURL(settings.URL, settings)
require.NoError(t, err)
require.NotNil(t, conn)
conn.Close()
require.NoError(t, conn.Close())
// TLS
@@ -45,7 +45,7 @@ func TestCreateConnectionForURL(t *testing.T) {
conn, err = createConnectionForURL(settings.URL, settings)
require.NoError(t, err)
require.NotNil(t, conn)
conn.Close()
require.NoError(t, conn.Close())
// Invalid TLS
+11 -14
View File
@@ -30,8 +30,11 @@ func NewService() Service {
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
// from the resource server and matching it with the user identifier setting.
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
token, err := GetOAuthToken(code, configuration)
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
token, err := GetOAuthToken(ctx, code, configuration)
if err != nil {
log.Error().Err(err).Msg("failed retrieving oauth token")
@@ -43,7 +46,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
log.Error().Err(err).Msg("failed parsing id_token")
}
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
if err != nil {
log.Error().Err(err).Msg("failed retrieving resource")
@@ -62,7 +65,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
return username, nil
}
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return nil, err
@@ -70,9 +73,6 @@ func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
config := buildConfig(configuration)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
return config.Exchange(ctx, unescapedCode)
}
@@ -87,9 +87,7 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
jwtParser := jwt.Parser{
SkipClaimsValidation: true,
}
jwtParser := jwt.Parser{SkipClaimsValidation: true}
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
if err != nil {
@@ -103,16 +101,15 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
func GetResource(token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
if err != nil {
return nil, err
}
client := &http.Client{}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
+7 -7
View File
@@ -18,14 +18,14 @@ func Test_getOAuthToken(t *testing.T) {
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
code := ""
if _, err := GetOAuthToken(code, config); err == nil {
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
}
})
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
code := validCode
token, err := GetOAuthToken(code, config)
token, err := GetOAuthToken(t.Context(), code, config)
if token == nil || err != nil {
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
@@ -92,19 +92,19 @@ func Test_getResource(t *testing.T) {
defer srv.Close()
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource("", config.ResourceURI); err == nil {
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
}
})
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
}
})
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
}
})
@@ -118,7 +118,7 @@ func Test_Authenticate(t *testing.T) {
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
defer srv.Close()
if _, err := authService.Authenticate(code, config); err == nil {
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
}
})
@@ -128,7 +128,7 @@ func Test_Authenticate(t *testing.T) {
srv, config := oauthtest.RunOAuthServer(code, config)
defer srv.Close()
username, err := authService.Authenticate(code, config)
username, err := authService.Authenticate(t.Context(), code, config)
if err != nil {
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
}
+51 -45
View File
@@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
)
@@ -20,38 +21,34 @@ type PendingActionsService struct {
var handlers = make(map[string]portainer.PendingActionHandler)
func NewService(
dataStore dataservices.DataStore,
kubeFactory *kubecli.ClientFactory,
) *PendingActionsService {
return &PendingActionsService{
dataStore: dataStore,
kubeFactory: kubeFactory,
mu: sync.Mutex{},
}
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
}
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
handlers[name] = handler
}
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
// Check if this pendingAction already exists
pendingActions, err := service.dataStore.PendingActions().ReadAll()
pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData)
})
if err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
}
for _, dba := range pendingActions {
if len(pendingActions) > 0 {
// Same endpoint, same action and data, don't create a repeat
if dba.EndpointID == action.EndpointID && dba.Action == action.Action &&
reflect.DeepEqual(dba.ActionData, action.ActionData) {
log.Debug().Msgf("pending action %s already exists for environment %d, skipping...", action.Action, action.EndpointID)
return nil
}
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
}
return service.dataStore.PendingActions().Create(&action)
return tx.PendingActions().Create(&action)
}
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
@@ -65,7 +62,8 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
if err != nil {
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
return
}
@@ -86,48 +84,55 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
// creating a kube client and performing a simple operation
client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
log.Debug().
Err(err).
Int("endpoint_id", int(environmentID)).
Msg("failed to create Kubernetes client for environment")
return
}
if _, err = client.ServerVersion(); err != nil {
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
log.Debug().
Err(err).
Str("endpoint_name", endpoint.Name).
Int("endpoint_id", int(environmentID)).
Msg("environment is not up")
return
}
}
pendingActions, err := service.dataStore.PendingActions().ReadAll()
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
return a.EndpointID == environmentID
})
if err != nil {
log.Warn().Msgf("failed to read pending actions: %v", err)
log.Warn().Err(err).Msg("failed to read pending actions")
return
}
if len(pendingActions) > 0 {
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
}
for i, pendingAction := range pendingActions {
if pendingAction.EndpointID == environmentID {
if i == 0 {
// We have at least 1 pending action for this environment
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
}
for _, pendingAction := range pendingActions {
log.Debug().
Int("pending_action_id", int(pendingAction.ID)).
Str("action", pendingAction.Action).
Msg("executing pending action")
if err := service.executePendingAction(pendingAction, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to execute pending action")
log.Debug().Msgf("executing pending action id=%d, action=%s", pendingAction.ID, pendingAction.Action)
err := service.executePendingAction(pendingAction, endpoint)
if err != nil {
log.Warn().Msgf("failed to execute pending action: %v", err)
continue
}
err = service.dataStore.PendingActions().Delete(pendingAction.ID)
if err != nil {
log.Warn().Msgf("failed to delete pending action: %v", err)
continue
}
log.Debug().Msgf("pending action %d finished", pendingAction.ID)
continue
}
if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil {
log.Warn().Err(err).Msg("failed to delete pending action")
continue
}
log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished")
}
}
@@ -140,7 +145,8 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
handler, ok := handlers[pendingAction.Action]
if !ok {
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
return nil
}
+30 -10
View File
@@ -141,6 +141,7 @@ type (
LogLevel *string
LogMode *string
KubectlShellImage *string
KubectlShellImageSet bool
PullLimitCheckDisabled *bool
TrustedOrigins *string
}
@@ -355,6 +356,20 @@ type (
CreatedBy string `example:"admin"`
}
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig struct {
// Path to a Helm chart folder for Helm git deployments
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
// Array of paths to Helm values YAML files for Helm git deployments
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
Version string `json:"Version,omitempty" example:"1.2.3"`
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
Atomic bool `json:"Atomic" example:"true"`
// Timeout for Helm operations (equivalent to helm --timeout flag)
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
}
EdgeStackStatusForEnv struct {
EndpointID EndpointID
Status []EdgeStackDeploymentStatus
@@ -544,11 +559,16 @@ type (
}
PolicyChartStatus struct {
ChartName string `json:"chartName"`
Fingerprint string `json:"fingerprint"`
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
// EnvironmentID is the endpoint this status belongs to.
// Stored so that ReadAll can group statuses by endpoint without parsing keys.
EnvironmentID EndpointID `json:"environmentID,omitempty"`
ChartName string `json:"chartName"`
Fingerprint string `json:"fingerprint"`
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
// Unix timestamp
LastAttemptTime int64 `json:"lastAttemptTime"`
}
ImageBundle struct {
@@ -557,7 +577,7 @@ type (
}
PolicyChartBundle struct {
PolicyChartSummary
PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
@@ -584,7 +604,7 @@ type (
// RestoreSettings contains instructions for restoring environment-level settings
RestoreSettings struct {
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
Manifest string `json:"manifest,omitempty"` // Base64-encoded Kubernetes YAML manifest
}
// RestoreSettingsBundle maps restore type to restoration instructions
@@ -1815,7 +1835,7 @@ type (
// OAuthService represents a service used to authenticate users using OAuth
OAuthService interface {
Authenticate(code string, configuration *OAuthSettings) (string, error)
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
}
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
@@ -1855,9 +1875,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.38.0"
APIVersion = "2.39.2"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
+2 -10
View File
@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
}
}
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
}); err != nil {
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
}
return err
}
return nil
})
}
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
@@ -44,6 +45,10 @@ func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityC
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
return nil, err
}
config := &ComposeStackDeploymentConfig{
stack: stack,
endpoint: endpoint,
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
)
@@ -43,6 +44,10 @@ func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityCon
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
if err := registryutils.ValidateRegistriesECRTokens(tx, filteredRegistries); err != nil {
return nil, err
}
config := &SwarmStackDeploymentConfig{
stack: stack,
endpoint: endpoint,
+34
View File
@@ -0,0 +1,34 @@
package stackutils
import (
"maps"
"os"
"path"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/compose-spec/compose-go/v2/dotenv"
)
// BuildEnvMap builds the environment variable map for stack validation/loading.
// Priority (lowest to highest): OS env → .env file → stack.Env
func BuildEnvMap(stack *portainer.Stack) map[string]string {
env := make(map[string]string, len(os.Environ()))
for _, e := range os.Environ() {
k, v, _ := strings.Cut(e, "=")
env[k] = v
}
dotEnvPath := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
if dotVars, err := dotenv.Read(dotEnvPath); err == nil {
maps.Copy(env, dotVars)
}
for _, pair := range stack.Env {
env[pair.Name] = pair.Value
}
return env
}
+38 -30
View File
@@ -1,38 +1,38 @@
package stackutils
import (
portainer "github.com/portainer/portainer/api"
"context"
"path"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/types"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
composeloader "github.com/compose-spec/compose-go/v2/loader"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/pkg/errors"
)
func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
type StackFileValidationConfig struct {
Content []byte
SecuritySettings *portainer.EndpointSecuritySettings
Env map[string]string
WorkingDir string
}
func IsValidStackFile(config StackFileValidationConfig) error {
composeConfigDetails := composetypes.ConfigDetails{
ConfigFiles: []composetypes.ConfigFile{{Content: config.Content}},
Environment: config.Env,
WorkingDir: config.WorkingDir,
}
composeConfig, err := composeloader.LoadWithContext(context.Background(), composeConfigDetails, composeloader.WithSkipValidation)
if err != nil {
return err
}
composeConfigFile := types.ConfigFile{
Config: composeConfigYAML,
}
composeConfigDetails := types.ConfigDetails{
ConfigFiles: []types.ConfigFile{composeConfigFile},
Environment: map[string]string{},
}
composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) {
options.SkipValidation = true
})
if err != nil {
return err
}
for key := range composeConfig.Services {
service := composeConfig.Services[key]
if !securitySettings.AllowBindMountsForRegularUsers {
for _, service := range composeConfig.Services {
if !config.SecuritySettings.AllowBindMountsForRegularUsers {
for _, volume := range service.Volumes {
if volume.Type == "bind" {
return errors.New("bind-mount disabled for non administrator users")
@@ -40,23 +40,23 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
}
}
if !securitySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
if !config.SecuritySettings.AllowPrivilegedModeForRegularUsers && service.Privileged {
return errors.New("privileged mode disabled for non administrator users")
}
if !securitySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
if !config.SecuritySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
return errors.New("pid host disabled for non administrator users")
}
if !securitySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
if !config.SecuritySettings.AllowDeviceMappingForRegularUsers && len(service.Devices) > 0 {
return errors.New("device mapping disabled for non administrator users")
}
if !securitySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
if !config.SecuritySettings.AllowSysctlSettingForRegularUsers && len(service.Sysctls) > 0 {
return errors.New("sysctl setting disabled for non administrator users")
}
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
if !config.SecuritySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
return errors.New("container capabilities disabled for non administrator users")
}
}
@@ -65,13 +65,21 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
}
func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error {
env := BuildEnvMap(stack)
workingDir := filesystem.JoinPaths(stack.ProjectPath, path.Dir(stack.EntryPoint))
for _, file := range GetStackFilePaths(stack, false) {
stackContent, err := fileService.GetFileContent(stack.ProjectPath, file)
if err != nil {
return errors.Wrap(err, "failed to get stack file content")
}
if err := IsValidStackFile(stackContent, securitySettings); err != nil {
if err := IsValidStackFile(StackFileValidationConfig{
Content: stackContent,
SecuritySettings: securitySettings,
Env: env,
WorkingDir: workingDir,
}); err != nil {
return errors.Wrap(err, "stack config file is invalid")
}
}
+225 -20
View File
@@ -1,10 +1,11 @@
package stackutils
import (
"os"
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
)
@@ -30,32 +31,236 @@ networks:
`)
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(yamlContent, securitySettings)
err := IsValidStackFile(StackFileValidationConfig{
Content: yamlContent,
SecuritySettings: securitySettings,
})
require.NoError(t, err)
}
func TestIsValidStackFile_PortEnv(t *testing.T) {
yamlContent := []byte(`
// TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects
// validation when the env var is not provided. Docker accepts an empty host port (left side)
// but requires a valid container port (right side).
func TestIsValidStackFile_MissingEnvVarBehavior(t *testing.T) {
securitySettings := &portainer.EndpointSecuritySettings{}
t.Run("var on left side only passes (docker allows :9090)", func(t *testing.T) {
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "${API_PORT}:9090"
`),
SecuritySettings: securitySettings,
})
require.NoError(t, err)
})
t.Run("var on right side fails", func(t *testing.T) {
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "9090:${API_PORT}"
`),
SecuritySettings: securitySettings,
})
require.Error(t, err)
})
t.Run("var on both sides fails", func(t *testing.T) {
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "${API_PORT}:${API_PORT}"
`),
SecuritySettings: securitySettings,
})
require.Error(t, err)
})
}
func TestIsValidStackFile_EnvVarInBothPortFields(t *testing.T) {
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(StackFileValidationConfig{
Content: []byte(`
version: "3"
services:
webservice:
api:
image: nginx
container_name: hello-world
networks:
- "mynet1"
ports:
- "${PORT}:80"
networks:
mynet1:
driver: bridge
ipam:
config:
- subnet: 172.16.0.0/24
`)
securitySettings := &portainer.EndpointSecuritySettings{}
err := IsValidStackFile(yamlContent, securitySettings)
- "${API_PORT}:${API_PORT}"
`),
SecuritySettings: securitySettings,
Env: map[string]string{"API_PORT": "3000"},
})
require.NoError(t, err)
}
type mockFileService struct {
portainer.FileService
fileContent []byte
projectVersionPath string
}
func (m mockFileService) GetFileContent(trustedRootPath, filePath string) ([]byte, error) {
return m.fileContent, nil
}
func (m mockFileService) FormProjectPathByVersion(projectPath string, version int, commitHash string) string {
return m.projectVersionPath
}
func TestValidateStackFiles_EnvVars(t *testing.T) {
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "${API_PORT}:${API_PORT}"
`)
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "docker-compose.yml",
Env: []portainer.Pair{{Name: "API_PORT", Value: "3000"}},
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: "/tmp/stack/1",
}
securitySettings := &portainer.EndpointSecuritySettings{}
err := ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_OSEnvVar(t *testing.T) {
t.Setenv("HOST_PORT", "3000")
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "80:${HOST_PORT}"
`)
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: "/tmp/stack/1",
}
securitySettings := &portainer.EndpointSecuritySettings{}
err := ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_DotEnvFile(t *testing.T) {
tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0600)
require.NoError(t, err)
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
ports:
- "80:${HOST_PORT}"
`)
stack := &portainer.Stack{
ProjectPath: tmpDir,
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: tmpDir,
}
securitySettings := &portainer.EndpointSecuritySettings{}
err = ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_EnvFileAttribute(t *testing.T) {
tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0600)
require.NoError(t, err)
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
env_file:
- ./web.env
`)
stack := &portainer.Stack{
ProjectPath: tmpDir,
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: tmpDir,
}
securitySettings := &portainer.EndpointSecuritySettings{}
err = ValidateStackFiles(stack, securitySettings, fileService)
require.NoError(t, err)
}
func TestValidateStackFiles_BindMountBlockedForNonAdmin(t *testing.T) {
fileContent := []byte(`
version: "3"
services:
api:
image: nginx
volumes:
- /host/path:/container/path
`)
stack := &portainer.Stack{
ProjectPath: "/tmp/stack/1",
EntryPoint: "docker-compose.yml",
}
fileService := mockFileService{
fileContent: fileContent,
projectVersionPath: "/tmp/stack/1",
}
securitySettings := &portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: false,
}
err := ValidateStackFiles(stack, securitySettings, fileService)
require.ErrorContains(t, err, "bind-mount disabled for non administrator users")
}
+21
View File
@@ -25,3 +25,24 @@ func StackResourceControlGetter[
func StackResourceControlID(endpointID portainer.EndpointID, name string) string {
return stackutils.ResourceControlID(endpointID, name)
}
type ExternalStack struct {
Labels map[string]string
}
// External stacks are indirectly detected either via containers or services labels
// Any UAC applied to them can only be fetched from containers'/services' labels
func ExternalStackResourceControlGetter[
TX txLike[RCS, TS, US],
RCS rcServiceLike,
TS teamServiceLike,
US userServiceLike,
](
tx TX,
endpointID portainer.EndpointID) func(item ExternalStack) (*portainer.ResourceControl, error) {
return genericResourcControlGetter(tx, endpointID, ResourceContext[ExternalStack]{
RCType: portainer.StackResourceControl,
IDGetter: func(s ExternalStack) string { return "0" },
LabelsGetter: func(es ExternalStack) map[string]string { return es.Labels },
})
}
+2 -2
View File
@@ -88,7 +88,7 @@ div.input-mask {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
background: var(--bg-widget-color);
border: 1px solid var(--border-widget);
border-radius: 8px;
border-radius: 12px;
}
.widget .widget-header .pagination,
.widget .widget-footer .pagination {
@@ -103,7 +103,7 @@ div.input-mask {
.widget .widget-body {
padding: 20px;
border-radius: 8px;
border-radius: 12px;
}
.widget .widget-body table thead {
background: var(--bg-widget-table-color);
+4 -7
View File
@@ -61,11 +61,8 @@ angular
.config(configApp);
if (require) {
const req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
req
.keys()
.filter((path) => !path.includes('.test'))
.forEach(function (key) {
req(key);
});
const req = require.context('./', true, /^(?!.*\.test\.js$).*\.js$/im);
req.keys().forEach(function (key) {
req(key);
});
}
+8 -6
View File
@@ -195,8 +195,7 @@ angular
},
views: {
'content@': {
templateUrl: './views/endpoints/edit/endpoint.html',
controller: 'EndpointController',
component: 'environmentsItemView',
},
},
};
@@ -256,8 +255,12 @@ angular
url: '/:id',
views: {
'content@': {
templateUrl: './views/groups/edit/group.html',
controller: 'GroupController',
component: 'environmentGroupEditView',
},
},
params: {
id: {
type: 'int',
},
},
};
@@ -267,8 +270,7 @@ angular
url: '/new',
views: {
'content@': {
templateUrl: './views/groups/create/creategroup.html',
controller: 'CreateGroupController',
component: 'environmentGroupCreateView',
},
},
};
@@ -1,17 +0,0 @@
import angular from 'angular';
import GroupFormController from './groupFormController';
angular.module('portainer.app').component('groupForm', {
templateUrl: './groupForm.html',
controller: GroupFormController,
bindings: {
loaded: '<',
model: '=',
associatedEndpoints: '=',
formAction: '<',
formActionLabel: '@',
actionInProgress: '<',
onChangeEnvironments: '<',
},
});

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