Compare commits

..

47 Commits

Author SHA1 Message Date
agent_coder
f379e8057e fix(stacks): keep stack breadcrumb on container Quick Actions
The stack container list reuses the shared containers datatable, whose
Quick Actions column linked to the global docker.containers.container.*
states with only {id,nodeName}. Clicking Logs/Stats/Console/Inspect/Attach
from within a stack therefore jumped to the global route and collapsed the
breadcrumb to "Containers > <name> > Logs", losing the stack trail that
PR #7 added.

Thread the current stack route params (via RowContext) down to
ContainerQuickActions so, when rendered inside a stack, its links target the
stack-scoped docker.stacks.stack.container.* sub-tab states (reusing #7's
buildStackContainerLinkParams / STACK_CONTAINER_STATE_NAME helpers). The
global containers list and service tasks pass no stack params and keep the
global links unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:52:44 +03:00
0e572f4ccc Merge pull request 'fix(stacks): keep stack breadcrumb trail when opening a container from a stack (#4)' (#7) from feat/4-stack-breadcrumbs into develop
Reviewed-on: #7
2026-07-01 02:23:40 +03:00
1e8f10f9cc Merge pull request 'feat(#1): remove all EE/Business-Edition features from the UI (pure-CE frontend)' (#5) from feat/1-remove-ee-ui into develop
Reviewed-on: #5
2026-07-01 02:23:07 +03:00
claude code agent
0bf4e71b79 fix(stacks): keep the stack breadcrumb trail on container attribute sub-tabs
When a container is opened from a stack, the detail tab kept the stack
trail (PR #7) but the attribute sub-tabs (Logs, Stats, Inspect, Console,
Attach) dropped it: those tabs were registered only under the global
docker.containers.container.* tree, so navigating to one left the stack
state (and its inherited params) behind, and each sub-view set a hardcoded
"Containers > ..." breadcrumb.

- Register stack-scoped child states docker.stacks.stack.container.{attach,
  exec,inspect,logs,stats} mirroring the global ones, so the inherited stack
  params survive and the trail can be kept.
- Centralize the breadcrumb logic in containerBreadcrumbs.ts (moved out of
  ItemView, which re-exports it) and add isStackContainerState +
  getContainerSubTabBreadcrumbs + buildStackContainerLinkParams.
- ActionLinksRow links sub-tabs into the stack tree (with stack+container
  params) when opened from a stack, else the global states unchanged.
- InspectView + the logs/stats/console controllers render the stack-aware
  trail; set up-front (no name) so it survives the load window and errors.

Covers regular/external/orphaned stacks and the non-stack fallback,
matching the existing ItemView breadcrumb behavior. New unit tests in
containerBreadcrumbs.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 02:25:04 +03:00
claude code agent
f27e44f5f2 refactor(ce): remove vestigial limitedToBE plumbing from BoxSelector (F8)
After the BE indicator was removed, limitedToBE was hardcoded false and threaded
through BoxSelectorItem.onSelect -> BoxSelector.onChange/handleSelect ->
BoxSelectorAngular.handleChange, where $setValidity(name, !limitedToBE) was a
permanent no-op (always valid). Drop the parameter from the whole chain and the
no-op $setValidity. That left formCtrl/require:'^form'/IFormController dead (they
existed only for that validity call), so remove them too — the component no longer
needs a parent form. The real on-change wiring ($evalAsync -> onChange(value)) is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:26:10 +03:00
claude code agent
5f16799b4c refactor(ce): drop orphaned OPEN_LDAP type and collapse degenerate buildUrl (F6,F7)
F6: remove SERVER_TYPES.OPEN_LDAP (read nowhere after the OpenLDAP retirement).
F7: the S3 callers that passed buildUrl(subResource, action) are gone; the only
    remaining caller uses buildUrl() with no args, so collapse it to return 'backup'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:49:32 +03:00
claude code agent
e9fae32b43 test(stacks): cover orphaned branch; name the stack-container state; type link params (F1,F2,F4)
Maintainer pre-merge review follow-up:
F1: test the orphaned-stack breadcrumb branch (orphaned=true, no regular) —
    href carries stackId/orphaned, not external.
F2: extract STACK_CONTAINER_STATE_NAME so code + test share one literal.
F4: type buildStackLinkParams' return as StackLinkParams (documents the real
    shape; external stays boolean, serialized by ui-router — no runtime change).
F3 (legacy ?id= deep links) answered wontfix in the PR thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:55:41 +03:00
claude code agent
28a06e80a8 refactor(ce): finish dead-BE removal — S3 backup, user-activity, LDAP, edition markers (F1-F5)
Maintainer pre-merge review follow-up (all non-blocker dead code):
F1: delete the dead S3-backup remnants (validation, query hooks, S3-only query
    key, BackupS3Model/Settings types) — kept the CE file-backup path.
F2: delete the orphaned user-activity services + their registration (kept the
    notifications component and routes).
F3: drop the unused buildOpenLDAPSettingsModel().
F4: drop the dead one-option ldap-options data (the selector was already collapsed).
F5: remove the dead data-edition attribute + its process.env typing; silence the
    intentional hasAuthorizations unused-params; drop the dead useRolesState meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:52:33 +03:00
claude code agent
90f51d48bb refactor(ce): drop orphaned access role column + no-op lodash compact (F6,F7)
F6: delete AccessDatatable/columns/role.tsx — the BE-only role column lost its
    only importer when useColumns stopped importing it; zero importers remain.
F7: useColumns wrapped only always-truthy helper.accessor results in _.compact,
    a no-op; return the plain array and drop the now-dead lodash import (same
    collapse already done in the parallel Wizard column files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:08:01 +03:00
claude code agent
b1b09e5da0 refactor(ce): remove leftover dead BE code — gates, orphans, dead selectors/CSS (F1-F4)
F1: drop the two HomeView edition-gate panels + their files (License/BackupFailed).
F2: delete zero-importer orphans (edition mutation, HubspotForm, HomepageFilter,
    relations mutation, ActivityLogsView cluster, ExperimentalFeatures subtree).
F3: collapse single-option selectors (Backup settings, init restore, env types)
    and delete the option files they orphaned.
F4: remove dead BE-teaser CSS rules and the --BE-only variable.
Also drop the orphaned .btn-warninglight BE-teaser variant.
F5 (limitedToBE) intentionally left — it is still read by BoxSelectorAngular.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:13:55 +03:00
claude code agent
a1851417d1 test(stacks): cover external-stack breadcrumb branch in buildStackLinkParams (F1)
Add a test case driving the external-stack branch (external='true', no DB
stackId) and assert the back-link carries external=true/type and omits
stackId/regular. stackId/regular are set in the route params so the negative
assertions actually catch a fall-through-to-regular regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:06:25 +03:00
claude code agent
b4d10a67b2 fix(stacks): preserve stack tab on breadcrumb back-link + assert default crumb (F1,F2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:55:47 +03:00
claude code agent
cb11b0fca4 fix(stacks): keep stack breadcrumb trail when opening a container from a stack (#4)
Opening a container from a stack's Containers table showed
"Home > Containers > <container>" instead of keeping the stack trail,
so the user could not navigate back to the stack.

Two root causes are addressed:

1. Route param collision: docker.stacks.stack used the query param `id`
   for the numeric stack DB id, while its child docker.stacks.stack.container
   uses the path param `id` for the container id. Navigating into a container
   overwrote the stack id. The stack id param is renamed `id` -> `stackId`
   everywhere it is read or written (route url, stacks datatable link,
   create-stack redirect, gitops workflow card link, stack ItemView reader).

2. Hardcoded breadcrumbs: the container details ItemView always rendered the
   global "Containers" crumb. Breadcrumbs are now state-aware: when reached
   via docker.stacks.stack.container the stack trail
   (Stacks > <stack> > <container>) is rebuilt from the inherited stack params,
   honoring external/orphaned stacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:44:18 +03:00
claude code agent
b7df90905d feat(ce): drop OCI "Business Feature" teaser and orphaned BE edge module
Remove the disabled "Installing from an OCI registry is a Portainer
Business Feature" option from the CE Helm repository selector so no
Business Feature teaser remains; CE Helm Repositories options are
unaffected.

Delete the orphaned AutomaticEdgeEnvCreation module (incl.
EnableWaitingRoomSwitch) — its render was already removed from
EdgeComputeSettingsView and nothing imports it anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:47:43 +03:00
claude code agent
ef47503bf8 feat(ce): tear down BE edition-gating engine
Delete the feature-flags edition machinery (isBE, init/selectShow/
isLimitedToBE, FeatureId/Edition/FeatureState enums, BEFeatureIndicator,
BEOverlay, BETeaserButton, withEdition, useLimitToBE, limitedFeatureDir)
now that all consumers are gone, drop the initFeatureService bootstrap,
and update tests/stories to assert CE-only behaviour. Mechanism B
(runtime FeatureFlag) and withHideOnExtension are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:33:27 +03:00
claude code agent
76896e5916 feat(ce): strip BE teaser plumbing from AngularJS layer
Unwrap be-feature-indicator / limited-feature directives and feature-id
attributes from LDAP/OAuth/access-management templates, delete BE-only
AngularJS views (Active Directory, OpenLDAP, RBAC access-viewer, auth
logs) and remove their registrations/routes and the r2a teaser-prop
allow-lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:33:27 +03:00
claude code agent
7dc98df2b6 feat(ce): remove BE chrome, routes, upsell banner and shared teaser props
Drop the Upgrade-to-Business banner, BE sidebar items (Licenses, Shared
Credentials, Edge Configurations, Waiting Room, Update & Rollback), BE
branding (BE logo/footer), and BE-only routed views (update-schedules,
EdgeAutoCreateScript, WaitingRoom, TimeWindowDisplay/Picker). Prune the
featureId/feature/BEFeatureID teaser props from shared components
(Switch, SwitchField, BoxSelector, TooltipWithChildren, wizard Option)
and fold isBE in useUser while preserving CE authorization semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:33:15 +03:00
claude code agent
cddccd2a5f feat(ce): remove BE teasers from Portainer React views
Collapse isBE/isLimitedToBE consumers and delete BE-only teaser UI in
settings, gitops, registries, custom templates, environments wizard,
access control, activity logs, registries and home/system views. The
Activity Audit view keeps its route but renders a plain CE empty state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:33:15 +03:00
claude code agent
003a90c235 feat(ce): collapse BE edition gating in Docker/Kubernetes/Edge views
Remove always-false isBE branches, BE-only teaser controls and the
now-dead imports across the Docker, Kubernetes and Edge-stack React
views. CE behaviour is preserved; only the Business Edition branches,
teasers and BE-only (non-functional) controls are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:33:03 +03:00
vvzvlad
9e9bb1bbff ci: install client deps with --no-frozen-lockfile explicitly
Some checks failed
Build image / build (push) Has been cancelled
pnpm ignores the npm_config_frozen_lockfile env var, so the previous fix did not take effect and CI still ran a frozen install. Add an explicit 'pnpm install --no-frozen-lockfile' step before 'make build-all' to reconcile the lockfile (missing pnpmfileChecksum for configDependencies); the subsequent frozen install in 'make client-deps' then succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:21:11 +03:00
vvzvlad
d0a0395337 ci: disable pnpm frozen-lockfile in build workflow
CI enables --frozen-lockfile by default, which fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed pnpm-lock.yaml does not record the pnpmfileChecksum for the configDependencies declared in package.json. Set npm_config_frozen_lockfile=false so the bare 'pnpm install' run by 'make client-deps' reconciles the lockfile instead of failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:18:59 +03:00
vvzvlad
88589e4cb3 ci: add GitHub Actions workflow to build and publish fork image to GHCR
Builds the CE fork (frontend + Go server) and publishes a drop-in compatible image to ghcr.io/vvzvlad/portainer-ce on pushes to develop, v* tags, and manual dispatch. Single-arch linux/amd64, alpine base, production frontend build (ENV=production), tags <package.json version> and latest, GHCR auth via the built-in GITHUB_TOKEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:14:36 +03:00
vvzvlad
af74986e66 chore: add VS Code workspace file 2026-06-28 18:55:18 +03:00
andres-portainer
e664bf0e19 fix(helm): add missing SSRF protections BE-13136 (#3001) 2026-06-22 20:25:10 -03:00
nickl-portainer
152c89972b chore(eslint): update eslint to latest v9 [R8S-1090] (#2954) 2026-06-23 11:04:33 +12:00
Oscar Zhou
25c69c6e9b fix(ui): update server installation timeout redirect link [BE-13124] (#2991) 2026-06-23 08:49:43 +12:00
andres-portainer
a6370808ae fix(ssrf): disable HTTP/2 for some specific cases BE-13121 (#2996) 2026-06-22 16:13:43 -03:00
Chaim Lev-Ari
6bfd2360d8 docs(security): add FAQ link to setup token messages [BE-13125] (#2995)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 20:30:49 +03:00
Chaim Lev-Ari
872d1e03f6 feat(gitops): add "create new source" button to GitSourceSelector [BE-13054] (#2960)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 17:19:53 +03:00
Chaim Lev-Ari
a5cacd712d refactor(gitops): remove manual credential entry from git form [BE-13047] (#2951)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 15:42:09 +03:00
Phil Calder
f596c862b3 fix(websocket): enforce environment authorization on kubernetes-shell [BE-13027] (#2774)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2026-06-22 15:09:41 +12:00
bernard-portainer
5395dee4c6 feat(gpu-stats): add gpu stats to environments [C9S-200] (#2735) 2026-06-22 09:21:43 +12:00
Josiah Clumont
217fe870ef fix(git): use ListContext instead of List when fetching remote refs [C9S-263] (#2939) 2026-06-22 08:30:20 +12:00
andres-portainer
26334e9088 feat(ssrf): add missing transport wrappings and more checks BE-13021 (#2968) 2026-06-19 20:26:03 -03:00
RHCowan
cc45af2873 fix(lint): enforce consistent golangci-lint version across CI and pre-commit [PLA-777] (#2966) 2026-06-19 11:45:12 +12:00
RHCowan
37bd8c06b5 fix(security): gate docker dashboard and edge async command routes [R8S-1057] (#2953) 2026-06-19 11:08:01 +12:00
andres-portainer
c821a1c59f fix(git): avoid cloning to memory and bypassing symlinking restriction BE-13115 (#2961) 2026-06-18 16:21:09 -03:00
Dakota Walsh
f5d0b3d849 feat(kubernetes): Gateway api client included in kubeclient [C9S-244] (#2884) 2026-06-18 14:37:42 +12:00
nickl-portainer
0dfd27f08c fix(pnpm): pnpm format command failing [R8S-1071] (#2932) 2026-06-18 13:27:01 +12:00
nickl-portainer
0dfa0266c7 fix(webpack): update shell-quote [R8S-1074] (#2934) 2026-06-17 10:50:48 +12:00
nickl-portainer
9b807ca314 fix(axios): update axios [R8S-1075] (#2935) 2026-06-17 10:50:34 +12:00
nickl-portainer
de5d84ade4 fix(kubernetes): handling undefined responseStatus [R8S-1072] (#2933) 2026-06-17 09:32:59 +12:00
Chaim Lev-Ari
4d539a691d feat(custom-templates): reuse existing git sources in create/update [BE-13053] (#2925)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:45:35 +03:00
Chaim Lev-Ari
ee8e73d7f9 feat(edge/stacks): use source ID for edge stack git creation [BE-13044] (#2926)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:33:19 +03:00
Chaim Lev-Ari
32c6bedb98 feat(stacks): use source for kubernetes manifest git stacks [BE-13045] (#2915)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 14:35:16 +03:00
Ali
cd9bb18ba1 feat(policies): reuse filter status component, give consistent styles [c9s-210] (#2723) 2026-06-16 15:58:33 +12:00
nickl-portainer
f365035563 fix(git): update lint-staged to v17 [R8S-1071] (#2907) 2026-06-16 15:14:57 +12:00
550 changed files with 3236 additions and 14355 deletions

View File

@@ -3,13 +3,13 @@ body:
attributes:
value: |
# Welcome!
Thanks for suggesting an idea for Portainer!
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
**DO NOT FILE DUPLICATE REQUESTS.**
- type: textarea

86
.github/workflows/build-image.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Build image
on:
push:
branches: [develop]
tags: ['v*']
workflow_dispatch: {}
env:
IMAGE: ghcr.io/vvzvlad/portainer-ce
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve version
id: ver
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Install client dependencies
# CI forces pnpm into --frozen-lockfile, which fails with
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed lockfile lacks
# the pnpmfileChecksum for the configDependencies in package.json.
# Reconcile the lockfile explicitly; the later frozen install in
# `make client-deps` then finds a matching lockfile. pnpm ignores the
# npm_config_frozen_lockfile env var, so an explicit flag is required.
run: pnpm install --no-frozen-lockfile
- name: Build client and server
env:
SKIP_GO_GET: "true"
CONTAINER_IMAGE_TAG: ${{ steps.ver.outputs.version }}
BUILDNUMBER: ${{ github.run_number }}
# Pin the embedded commit to the full SHA so it matches the image
# GIT_COMMIT build-arg and does not depend on the shallow checkout.
GIT_COMMIT_HASH: ${{ github.sha }}
# ENV=production selects webpack/webpack.production.js (minified bundle),
# matching the official CE image; the Makefile default is development.
run: make build-all ENV=production
- name: Ensure storybook directory exists
# make build-all does not produce dist/storybook, but alpine.Dockerfile
# has `COPY dist/storybook* /storybook/`; without a match the docker build fails.
run: mkdir -p dist/storybook
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image (linux/amd64, alpine base)
uses: docker/build-push-action@v6
with:
context: .
file: build/linux/alpine.Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
${{ env.IMAGE }}:latest
build-args: |
GIT_COMMIT=${{ github.sha }}

View File

@@ -1,4 +1,4 @@
version: "2"
version: '2'
linters:
default: none
enable:

View File

@@ -1,4 +1,4 @@
version: "2"
version: '2'
run:
allow-parallel-runners: true
@@ -32,7 +32,7 @@ linters:
- exptostd
settings:
staticcheck:
checks: ["all", "-ST1003", "-ST1005", "-ST1016", "-SA1019", "-QF1003"]
checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003']
depguard:
rules:
main:
@@ -91,7 +91,7 @@ linters:
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
- pattern: ^object\.(Commit|Tag)\.Verify$
msg: "Not allowed because of FIPS mode"
msg: 'Not allowed because of FIPS mode'
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
msg: 'Not allowed because of FIPS mode'
- pattern: ^git\.PlainClone(Context|WithOptions)?$
@@ -108,6 +108,9 @@ linters:
linters:
- gocritic
text: ruleguard
- path: pkg/libhttp/ssrf/builder\.go
linters:
- forbidigo
paths:
- third_party$
- builtin$

View File

@@ -1,3 +1,5 @@
dist
api/datastore/test_data
coverage
coverage
pnpm-lock.yaml

View File

@@ -6,6 +6,7 @@ TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6
GOTESTSUM_VERSION?=v1.13.0
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version)
# Don't change anything below this line unless you know what you're doing
.DEFAULT_GOAL := help
@@ -90,13 +91,25 @@ format-server: ## Format server code
go fmt ./...
##@ Lint
.PHONY: lint lint-client lint-server
.PHONY: lint lint-client lint-server check-lint-version
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
pnpm run lint
lint-server: tidy ## Lint server code
check-lint-version:
@installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
if [ "$$installed" = "v" ]; then \
echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
exit 1; \
elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \
echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
exit 1; \
fi
lint-server: tidy check-lint-version ## Lint server code
golangci-lint run --timeout=10m -c .golangci.yaml
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml

View File

@@ -4,38 +4,72 @@ package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// unwrappedHTTPTransport flags http.Transport composite literals that are not
// the direct argument to ssrf.WrapTransport.
// unwrappedHTTPTransport flags any bare http.Transport composite literal.
// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport,
// which clone http.DefaultTransport and handle SSRF protection internally.
func unwrappedHTTPTransport(m dsl.Matcher) {
// Inline construction passed to a function call.
m.Match(`$f(&http.Transport{$*_})`).
Where(m["f"].Text != "ssrf.WrapTransport" && m["f"].Text != "WrapTransport" &&
m["f"].Text != "ssrf.WrapTransportInternal" && m["f"].Text != "WrapTransportInternal").
Report(`$f receives a bare *http.Transport; wrap with ssrf.WrapTransport() to enforce the SSRF protection policy`)
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
// Variable assigned a bare transport (cannot be tracked to a later WrapTransport call).
m.Match(`$_ := &http.Transport{$*_}`).
Report(`bare *http.Transport variable; use ssrf.WrapTransport(&http.Transport{...}) inline instead`)
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
// Field assignment of a bare transport (e.g. httpClient.Transport = &http.Transport{...}).
m.Match(`$_.Transport = &http.Transport{$*_}`).
Report(`bare *http.Transport field assignment; wrap with ssrf.WrapTransport() to enforce the SSRF protection policy`)
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
}
// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport.
// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport
// passed here must be wrapped with ssrf.WrapTransport.
// passed here must be created via ssrf.NewTransport.
func helmGetterTransport(m dsl.Matcher) {
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
Report(`getter.WithTransport called with a bare *http.Transport; wrap with ssrf.WrapTransport() as Helm v4 bypasses http.DefaultTransport`)
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
}
// internalTransportMisuse flags calls to WrapTransportInternal outside the four proxy
// cloneDefaultTransport flags direct clones of *http.Transport outside main.go.
// The one legitimate clone is in main.go where http.DefaultTransport is globally
// wrapped with SSRF protection at server startup.
func cloneDefaultTransport(m dsl.Matcher) {
m.Match(`$_.(*http.Transport).Clone()`).
Where(!m.File().Name.Matches(`^main\.go$`)).
Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
}
// internalTransportMisuse flags calls to NewInternalTransport outside the proxy
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
func internalTransportMisuse(m dsl.Matcher) {
m.Match(`ssrf.WrapTransportInternal($*_)`).
m.Match(`ssrf.NewInternalTransport($*_)`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport)\.go$`))).
Report(`WrapTransportInternal bypasses SSRF validation; only valid in the kubernetes local/edge transport constructors and the docker/agent proxy factories`)
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))).
Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`)
}
// dialerOverride flags direct assignments to any of the dialer fields on a transport.
// The only valid assignments are in docker_unix.go and docker_windows.go where a
// custom dialer is required for unix sockets and named pipes.
func dialerOverride(m dsl.Matcher) {
m.Match(`$_.DialContext = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.Dial = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.DialTLSContext = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
m.Match(`$_.DialTLS = $*_`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
}

View File

@@ -28,7 +28,7 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
httpCli := &http.Client{Timeout: 3 * time.Second}
if tlsConfig != nil {
httpCli.Transport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
httpCli.Transport = ssrf.NewTransport(tlsConfig)
}
parsedURL, err := url.ParseURL(endpointUrl + "/ping")

View File

@@ -411,8 +411,8 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
log.Fatal().Err(err).Msg("failed initializing ssrf service")
}
if dt, ok := nethttp.DefaultTransport.(*nethttp.Transport); ok {
nethttp.DefaultTransport = ssrf.WrapTransport(dt)
if !ssrf.WrapDefaultTransport() {
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
}
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})

View File

@@ -195,20 +195,9 @@ type (
BucketName() string
}
SourceServiceUserContext struct {
User *portainer.User
UserMemberships []portainer.TeamMembership
}
// SourceService represents a service for managing GitOps source data
SourceService interface {
Create(context *SourceServiceUserContext, source *portainer.Source) error
Read(context *SourceServiceUserContext, ID portainer.SourceID) (*portainer.Source, error)
Exists(context *SourceServiceUserContext, ID portainer.SourceID) (bool, error)
ReadAll(context *SourceServiceUserContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error)
Update(context *SourceServiceUserContext, ID portainer.SourceID, source *portainer.Source) error
Delete(context *SourceServiceUserContext, ID portainer.SourceID) error
FindOrCreateGitSource(context *SourceServiceUserContext, source *portainer.Source) (*portainer.Source, error)
BaseCRUD[portainer.Source, portainer.SourceID]
}
// StackService represents a service for managing stack data

View File

@@ -1,133 +0,0 @@
package source
import (
"errors"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
)
var (
ErrInvalidSource = errors.New("invalid source")
ErrInvalidUserContext = errors.New("invalid user context")
ErrNotEnoughPermission = errors.New("not enough permissions to perform this action")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
)
func validateUserContext(ctx *userContext) error {
if ctx == nil || ctx.User == nil {
return ErrInvalidUserContext
}
return nil
}
type actionType string
const (
actionRead actionType = "read"
actionWrite actionType = "write"
)
func enforceUserPermissions(ctx *userContext, source *portainer.Source, action actionType) error {
if action == actionRead && userCanReadSource(source, ctx) {
return nil
}
if action == actionWrite && userCanWriteSource(source, ctx) {
return nil
}
return ErrNotEnoughPermission
}
func userCanWriteSource(source *portainer.Source, context *userContext) bool {
if source == nil || context == nil || context.User == nil {
return false
}
user := context.User
if user.Role == portainer.AdministratorRole {
return true
}
if source.OwnerID != 0 && source.OwnerID == user.ID && userCanReadSource(source, context) {
return true
}
return false
}
func filterSources(sources []portainer.Source, context *userContext) []portainer.Source {
return slicesx.Filter(sources, func(s portainer.Source) bool {
return userCanReadSource(&s, context)
})
}
func userCanReadSource(source *portainer.Source, context *userContext) bool {
if source == nil || context == nil || context.User == nil {
return false
}
user := context.User
userTeams := context.UserMemberships
if user.Role == portainer.AdministratorRole || source.Public {
return true
}
if source.AdministratorsOnly {
return false
}
if slices.Contains(source.UserAccesses, user.ID) {
return true
}
if len(userTeams) == 0 || len(source.TeamAccesses) == 0 {
return false
}
sTeams := set.ToSet(source.TeamAccesses)
uTeams := set.ToSet(slicesx.Map(userTeams, func(u portainer.TeamMembership) portainer.TeamID { return u.TeamID }))
return set.Intersection(sTeams, uTeams).Len() != 0
}
// enforceUniqueGitSource validates there are no other git sources with the same URL and credentials
// It ignores itself
func enforceUniqueGitSource(tx ServiceTx, src *portainer.Source) error {
if src.Type != portainer.SourceTypeGit || src.Git == nil {
return nil
}
normalized, err := normalizeGitSource(src)
if err != nil {
return err
}
existing, err := tx.base.ReadAll(func(s portainer.Source) bool {
if src.ID == s.ID {
return false
}
n, err := normalizeGitSource(&s)
if err != nil {
return false
}
return normalized.Equal(n)
})
if err != nil {
return err
}
if len(existing) > 0 {
return ErrDuplicateSource
}
return nil
}

View File

@@ -1,43 +0,0 @@
package source
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
type normalizedGitSource struct {
url string
username string
password string
}
func (a *normalizedGitSource) Equal(b *normalizedGitSource) bool {
return a != nil && b != nil &&
a.url == b.url &&
a.username == b.username &&
a.password == b.password
}
// normalize git source to a lighter object used to compare sources together
func normalizeGitSource(src *portainer.Source) (*normalizedGitSource, error) {
if src == nil || src.Type != portainer.SourceTypeGit || src.Git == nil {
return nil, ErrInvalidSource
}
url, err := gittypes.NormalizeURL(gittypes.SanitizeURL(src.Git.URL))
if err != nil {
return nil, err
}
username, password := "", ""
if src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
return &normalizedGitSource{
url: url,
username: username,
password: password,
}, nil
}

View File

@@ -1,74 +0,0 @@
package source
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
// Sanitize the source URL and enforce fields values based on user context
func sanitizeGitSource(source *portainer.Source) error {
if source == nil {
return ErrInvalidSource
}
if source.Type != portainer.SourceTypeGit {
return nil
}
if source.Git == nil {
return ErrInvalidSource
}
var err error
source.Git.URL, err = gittypes.NormalizeURL(gittypes.SanitizeURL(source.Git.URL))
if err != nil {
return err
}
return nil
}
func sanitizeAccesses(ctx *userContext, newValues *portainer.Source, previousValues *portainer.Source) error {
if newValues == nil {
return ErrInvalidSource
}
if ctx.User.Role == portainer.AdministratorRole {
if newValues.Public && newValues.AdministratorsOnly {
newValues.Public = false
}
if newValues.Public || newValues.AdministratorsOnly {
newValues.UserAccesses = []portainer.UserID{}
newValues.TeamAccesses = []portainer.TeamID{}
}
if !newValues.Public && !newValues.AdministratorsOnly && len(newValues.UserAccesses) == 0 && len(newValues.TeamAccesses) == 0 {
newValues.AdministratorsOnly = true
}
return nil
}
// Update flow ; regular user is not allowed to change the UAC, visibility or ownership of the source
if previousValues != nil {
newValues.UserAccesses = previousValues.UserAccesses
newValues.TeamAccesses = previousValues.TeamAccesses
newValues.Public = previousValues.Public
newValues.AdministratorsOnly = previousValues.AdministratorsOnly
newValues.OwnerID = previousValues.OwnerID
return nil
}
// Create flow
userAccesses := []portainer.UserID{ctx.User.ID}
if newValues.Public {
userAccesses = []portainer.UserID{}
}
newValues.UserAccesses = userAccesses
newValues.TeamAccesses = []portainer.TeamID{}
newValues.AdministratorsOnly = false
newValues.OwnerID = ctx.User.ID
return nil
}

View File

@@ -1,72 +0,0 @@
package source
import (
"testing"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/require"
)
type vFn func(new *portainer.Source, old *portainer.Source, err error)
func testUAC(
t *testing.T,
userContext *userContext,
new *portainer.Source,
old *portainer.Source,
validationFuncs ...vFn,
) {
t.Helper()
err := sanitizeAccesses(userContext, new, old)
for _, validate := range validationFuncs {
validate(new, old, err)
}
}
func Test_SanitizeAccesses_Admin(t *testing.T) {
errInvalidSource := func(_, _ *portainer.Source, err error) {
t.Helper()
require.ErrorIs(t, err, ErrInvalidSource)
}
noError := func(_, _ *portainer.Source, err error) { t.Helper(); require.NoError(t, err) }
noOwner := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Zero(t, new.OwnerID) }
emptyUsers := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.UserAccesses) }
emptyTeams := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.TeamAccesses) }
public := func(v bool) func(new, _ *portainer.Source, _ error) {
return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.Public) }
}
adminOnly := func(v bool) func(new, _ *portainer.Source, _ error) {
return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.AdministratorsOnly) }
}
adminUserContext := NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{})
test := func(new *portainer.Source, old *portainer.Source, validationFuncs ...vFn) {
t.Helper()
testUAC(t, adminUserContext, new, old, validationFuncs...)
}
test(nil, nil, errInvalidSource)
test(&portainer.Source{}, nil, noError)
test(&portainer.Source{Git: &gittypes.RepoConfig{}}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, Public: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(false), noOwner, public(true),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, AdministratorsOnly: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, AdministratorsOnly: true, Public: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, AdministratorsOnly: true, Public: true, UserAccesses: []portainer.UserID{1, 2}}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
}
// func Test_SanitizeAccesses_User(t *testing.T) {
// user := NewUserContext(&portainer.User{Role: portainer.StandardUserRole}, []portainer.TeamMembership{})
// }

View File

@@ -10,7 +10,7 @@ const BucketName = "sources"
// Service represents a service for managing GitOps source data.
type Service struct {
base dataservices.BaseDataService[portainer.Source, portainer.SourceID]
dataservices.BaseDataService[portainer.Source, portainer.SourceID]
}
// NewService creates a new instance of a service.
@@ -21,7 +21,7 @@ func NewService(connection portainer.Connection) (*Service, error) {
}
return &Service{
base: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
BaseDataService: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: connection,
},
@@ -30,77 +30,21 @@ func NewService(connection portainer.Connection) (*Service, error) {
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
base: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: service.base.Connection,
Connection: service.Connection,
Tx: tx,
},
}
}
// Create creates a new source.
func (service *Service) Create(context *userContext, source *portainer.Source) error {
return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Create(context, source)
})
}
func (service *Service) Read(context *userContext, ID portainer.SourceID) (*portainer.Source, error) {
var result *portainer.Source
err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).Read(context, ID)
return err
})
return result, err
}
func (service *Service) Exists(context *userContext, ID portainer.SourceID) (bool, error) {
var result bool
err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).Exists(context, ID)
return err
})
return result, err
}
func (service *Service) ReadAll(context *userContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) {
var result []portainer.Source
err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).ReadAll(context, predicates...)
return err
})
return result, err
}
func (service *Service) Update(context *userContext, ID portainer.SourceID, source *portainer.Source) error {
return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Update(context, ID, source)
})
}
func (service *Service) Delete(context *userContext, ID portainer.SourceID) error {
return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Delete(context, ID)
})
}
func (service *Service) FindOrCreateGitSource(context *userContext, source *portainer.Source) (*portainer.Source, error) {
var result *portainer.Source
err := service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).FindOrCreateGitSource(context, source)
return err
})
return result, err
func (service *Service) Create(source *portainer.Source) error {
return service.Connection.CreateObject(
BucketName,
func(id uint64) (int, any) {
source.ID = portainer.SourceID(id)
return int(source.ID), source
},
)
}

View File

@@ -3,36 +3,15 @@ package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
)
type ServiceTx struct {
base dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
}
// Create creates a new source.
func (service ServiceTx) Create(context *userContext, source *portainer.Source) error {
if err := validateUserContext(context); err != nil {
return err
}
if source == nil {
return ErrInvalidSource
}
if err := sanitizeGitSource(source); err != nil {
return err
}
if err := sanitizeAccesses(context, source, nil); err != nil {
return err
}
if err := enforceUniqueGitSource(service, source); err != nil {
return err
}
return service.base.Tx.CreateObject(
func (service ServiceTx) Create(source *portainer.Source) error {
return service.Tx.CreateObject(
BucketName,
func(id uint64) (int, any) {
source.ID = portainer.SourceID(id)
@@ -40,165 +19,3 @@ func (service ServiceTx) Create(context *userContext, source *portainer.Source)
},
)
}
func (service ServiceTx) Read(context *userContext, ID portainer.SourceID) (*portainer.Source, error) {
if err := validateUserContext(context); err != nil {
return nil, err
}
source, err := service.base.Read(ID)
if err != nil {
return nil, err
}
if err := enforceUserPermissions(context, source, actionRead); err != nil {
return nil, err
}
return source, err
}
// Access is not enforced on this to avoid the cost of deserialize
// Any user can scan the DB IDs using this method, so be mindful with usage of this func.
func (service ServiceTx) Exists(context *userContext, ID portainer.SourceID) (bool, error) {
if err := validateUserContext(context); err != nil {
return false, err
}
return service.base.Exists(ID)
}
// ReadAll fetches all sources the user can access, matching predicates
func (service ServiceTx) ReadAll(context *userContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) {
if err := validateUserContext(context); err != nil {
return nil, err
}
list, err := service.base.ReadAll(predicates...)
if err != nil {
return nil, err
}
return filterSources(list, context), nil
}
// Update updates the source of id `ID` with the `source` content
// It validates that the user has access to the source, and has enough permissions to perform the action
func (service ServiceTx) Update(context *userContext, ID portainer.SourceID, source *portainer.Source) error {
if err := validateUserContext(context); err != nil {
return err
}
originalSource, err := service.base.Read(ID)
if err != nil {
return err
}
if source == nil || originalSource == nil {
return ErrInvalidSource
}
if err := enforceUserPermissions(context, originalSource, actionWrite); err != nil {
return err
}
if err := sanitizeGitSource(source); err != nil {
return err
}
if err := sanitizeAccesses(context, source, originalSource); err != nil {
return err
}
if err := enforceUniqueGitSource(service, source); err != nil {
return err
}
return service.base.Update(ID, source)
}
// Delete deletes a source
// It validates that the user has access to the source, and has enough permissions to perform the action
func (service ServiceTx) Delete(context *userContext, ID portainer.SourceID) error {
if err := validateUserContext(context); err != nil {
return err
}
source, err := service.base.Read(ID)
if err != nil {
return err
}
if err := enforceUserPermissions(context, source, actionWrite); err != nil {
return err
}
return service.base.Delete(ID)
}
// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg,
// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source;
// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact.
// The function auto adds the user to an existing source if the user doesn't have access but provided a valid full
// config (URL+Auth)
func (service ServiceTx) FindOrCreateGitSource(context *userContext, src *portainer.Source) (*portainer.Source, error) {
if err := validateUserContext(context); err != nil {
return nil, err
}
if src == nil || src.Git == nil {
return nil, ErrInvalidSource
}
normalized, err := normalizeGitSource(src)
if err != nil {
return nil, err
}
existing, err := service.base.ReadAll(func(s portainer.Source) bool {
n, err := normalizeGitSource(&s)
if err != nil {
return false
}
return normalized.Equal(n)
})
if err != nil {
return nil, err
}
if len(existing) > 0 {
allowed := filterSources(existing, context)
if len(allowed) > 0 {
return &allowed[0], nil
}
// give user access to the first source if he doesn't have access
// to any of the sources that have the same url+auth
existing[0].UserAccesses = append(existing[0].UserAccesses, context.User.ID)
if err := service.base.Update(existing[0].ID, &existing[0]); err != nil {
return nil, err
}
return &existing[0], nil
}
toCreate := &portainer.Source{
Name: src.Name,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: src.Git.URL,
Authentication: src.Git.Authentication,
TLSSkipVerify: src.Git.TLSSkipVerify,
},
Public: src.Public,
AdministratorsOnly: src.AdministratorsOnly,
UserAccesses: src.UserAccesses,
TeamAccesses: src.TeamAccesses,
OwnerID: src.OwnerID,
}
if err := service.Create(context, toCreate); err != nil {
return nil, err
}
return toCreate, nil
}

View File

@@ -1,21 +0,0 @@
package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
type userContext = dataservices.SourceServiceUserContext
// Create a new admin context
//
// # THIS FUNCTION MUST NOT BE USED IN A USER-AWARE FLOW, ONLY FOR MIGRATIONS AND TESTS
//
// The only flows outside of migrations/test allowed to use this func is the datastore.Import/Export for sources
func InsecureNewAdminContext() *userContext {
return NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{})
}
func NewUserContext(user *portainer.User, userMemberships []portainer.TeamMembership) *userContext {
return &userContext{User: user, UserMemberships: userMemberships}
}

View File

@@ -1,97 +0,0 @@
package source
// var adminUserContext = InsecureNewAdminContext()
// func newSourceWithAuth(url, username, password string) *portainer.Source {
// return &portainer.Source{
// Type: portainer.SourceTypeGit,
// Git: &gittypes.RepoConfig{
// URL: url,
// Authentication: &gittypes.GitAuthentication{
// Username: username,
// Password: password,
// },
// },
// }
// }
// func newAuthlessSource(url string) *portainer.Source {
// return &portainer.Source{
// Type: portainer.SourceTypeGit,
// Git: &gittypes.RepoConfig{URL: url},
// }
// }
// func validateUniqueSourceInStore(t *testing.T, tx ServiceTx, url, username, password string, sourceID portainer.SourceID) bool {
// t.Helper()
// var isUnique bool
// require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
// var err error
// isUnique, err =// enforceUniqueGitSource(tx, url, username, password, sourceID)
// return err
// }))
// return isUnique
// }
// func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
// }))
// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
// }
// func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
// }
// func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git"))
// }))
// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
// }
// func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git"))
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
// }
// func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// var srcID portainer.SourceID
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
// if err := tx.Source().Create(adminUserContext, src); err != nil {
// return err
// }
// srcID = src.ID
// return nil
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
// }

View File

@@ -4,7 +4,6 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/dataservices/stack"
gittypes "github.com/portainer/portainer/api/git/types"
@@ -53,11 +52,9 @@ func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
}
type legacyStack struct {
ID int `json:"Id"`
GitConfig *legacyRepoConfig `json:"GitConfig"`
WorkflowID *int
ResourceControl *portainer.ResourceControl `json:"ResourceControl"`
CreatedBy string
ID int `json:"Id"`
GitConfig *legacyRepoConfig `json:"GitConfig"`
WorkflowID *int
}
// sourceDedupeKey is the identity used to detect duplicate Sources during migration.
@@ -101,8 +98,7 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
return err
}
adminUserContext := source.InsecureNewAdminContext()
existingSources, err := m.sourceService.ReadAll(adminUserContext)
existingSources, err := m.sourceService.ReadAll()
if err != nil {
return err
}
@@ -126,53 +122,19 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
var newSrcID portainer.SourceID
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(ls.ResourceControl,
func() (portainer.UserID, portainer.UserRole, error) {
user, err := m.userService.Tx(tx).UserByUsername(ls.CreatedBy)
if err != nil {
return 0, 0, err
}
return user.ID, user.Role, nil
},
func(userId portainer.UserID) ([]portainer.TeamMembership, error) {
return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId)
},
)
srcID, exists := sourcesByKey[key]
if !exists {
src := &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: cfg.URL,
Authentication: cfg.Authentication,
TLSSkipVerify: cfg.TLSSkipVerify,
},
OwnerID: ownerId,
Public: public,
AdministratorsOnly: adminOnly,
UserAccesses: users,
TeamAccesses: teams,
Git: cfg,
}
if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil {
if err := m.sourceService.Tx(tx).Create(src); err != nil {
return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err)
}
srcID = src.ID
newSrcID = src.ID
} else {
src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID)
if err != nil {
return fmt.Errorf("failed to read source %d for stack %d: %w", srcID, ls.ID, err)
}
ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId)
if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil {
return fmt.Errorf("failed to update source %d for stack %d: %w", srcID, ls.ID, err)
}
}
liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID))
@@ -220,8 +182,7 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
return err
}
adminUserContext := source.InsecureNewAdminContext()
existingSources, err := m.sourceService.ReadAll(adminUserContext)
existingSources, err := m.sourceService.ReadAll()
if err != nil {
return err
}
@@ -250,48 +211,19 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
var newSrcID portainer.SourceID
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(t.ResourceControl,
func() (portainer.UserID, portainer.UserRole, error) {
user, err := m.userService.Tx(tx).Read(t.CreatedByUserID)
if err != nil {
return 0, 0, err
}
return user.ID, user.Role, nil
},
func(userId portainer.UserID) ([]portainer.TeamMembership, error) {
return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId)
},
)
srcID, exists := sourcesByKey[key]
if !exists {
src := &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
OwnerID: ownerId,
Public: public,
AdministratorsOnly: adminOnly,
UserAccesses: users,
TeamAccesses: teams,
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
}
if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil {
if err := m.sourceService.Tx(tx).Create(src); err != nil {
return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err)
}
srcID = src.ID
newSrcID = src.ID
} else {
src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID)
if err != nil {
return fmt.Errorf("failed to read source %d for custom template %d: %w", srcID, t.ID, err)
}
ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId)
if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil {
return fmt.Errorf("failed to update source %d for custom template %d: %w", srcID, t.ID, err)
}
}
t.Artifact = &portainer.Artifact{

View File

@@ -15,10 +15,6 @@ import (
"github.com/stretchr/testify/require"
)
// TODO: generate tests for UAC migrations
var adminUserContext = source.InsecureNewAdminContext()
func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) {
t.Parallel()
@@ -65,12 +61,11 @@ func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) {
require.Len(t, wf.Artifacts, 1)
require.Len(t, wf.Artifacts[0].Files, 1)
src, err := sourceSvc.Read(adminUserContext, wf.Artifacts[0].Files[0].SourceID)
src, err := sourceSvc.Read(wf.Artifacts[0].Files[0].SourceID)
require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, gitStack.GitConfig.URL, src.Git.URL)
require.Empty(t, src.Git.ReferenceName)
require.Equal(t, gitStack.GitConfig.ReferenceName, wf.Artifacts[0].Files[0].Ref)
require.Equal(t, gitStack.GitConfig.ReferenceName, src.Git.ReferenceName)
}
func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
@@ -109,7 +104,7 @@ func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
require.Zero(t, result.WorkflowID)
require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, sources)
@@ -165,7 +160,7 @@ func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T)
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1, "two stacks with the same URL must share one Source")
@@ -219,7 +214,7 @@ func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1)
@@ -273,7 +268,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *t
require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path)
require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash)
src, err := sourceSvc.Read(adminUserContext, migrated.Artifact.Files[0].SourceID)
src, err := sourceSvc.Read(migrated.Artifact.Files[0].SourceID)
require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
@@ -312,7 +307,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(
require.Nil(t, result.Artifact)
require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, sources)
}
@@ -355,7 +350,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Empty(t, sources, "no new sources should be created for already-migrated templates")
}
@@ -407,7 +402,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source")
@@ -461,7 +456,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll(adminUserContext)
sources, err := sourceSvc.ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1)
}

View File

@@ -1,122 +0,0 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
"github.com/rs/zerolog/log"
)
// DB accesses enforcement are trying to restrict accesses as much as possible
// but because accesses are applied sequentially, we want the accesses to be more open on migration
// so that users retain their accesses
func ApplyUACOnSourceUpdate_2_43_0(source *portainer.Source,
users []portainer.UserID, teams []portainer.TeamID,
public bool, adminOnly bool,
ownerId portainer.UserID,
) {
// sources already public should remain public
// OR
// the resource using this source is public, so the source should be public
if source.Public || public {
source.Public = true
source.AdministratorsOnly = false
source.UserAccesses = []portainer.UserID{}
source.TeamAccesses = []portainer.TeamID{}
return
}
// add users and teams to source accesses only if the incoming resource is not admninonly
// to avoid saving leftover user/teams from adminonly resources
if !adminOnly {
source.UserAccesses = slicesx.Unique(append(source.UserAccesses, users...))
source.TeamAccesses = slicesx.Unique(append(source.TeamAccesses, teams...))
}
// regardless of the incoming resource's ResourceControl values (func params)
// no accesses means adminonly source not owned by anyone
// we don't want users to own sources they don't have access to
// neither we want to default them to public
// all in all as we are doing an update it's probably redundant, but just in case...
if len(source.UserAccesses) == 0 && len(source.TeamAccesses) == 0 {
source.AdministratorsOnly = true
source.OwnerID = 0
return
}
// if owner of the incoming resource (ownerid) is the only one with access, we give the ownership to the user.
// The source could previously be adminonly so we change that as well as we want the most open situation
if len(source.UserAccesses) == 1 && len(source.TeamAccesses) == 0 && ownerId == source.UserAccesses[0] {
source.OwnerID = ownerId
source.AdministratorsOnly = false
return
}
// Anything else will have multiple accesses (multiple teams or users), from multiple resources (source update flow)
// So we remove the ownership of the source in case it existed
// Scenario:
// - source created for resource owned by Bob
// - now we try to update with the RC from an admin-owned resource, shared to other users/teams
// - we don't want Bob to own the source anymore
source.OwnerID = 0
}
func GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(
rc *portainer.ResourceControl,
getCreator func() (portainer.UserID, portainer.UserRole, error),
getCreatorMemberships func(portainer.UserID) ([]portainer.TeamMembership, error),
) (
users []portainer.UserID, teams []portainer.TeamID,
public bool, adminOnly bool,
ownerId portainer.UserID,
) {
users = []portainer.UserID{}
teams = []portainer.TeamID{}
public = false
adminOnly = true
if rc == nil {
return
}
adminOnly = rc.AdministratorsOnly
public = rc.Public
if adminOnly || public {
return
}
// only transfer users/teams when the stack is not admin nor public
// this allows avoiding transfering access of sources to users/teams that don't have real access to the stack
// but that may have had their accesses retained in DB
users = slicesx.Map(rc.UserAccesses, func(ura portainer.UserResourceAccess) portainer.UserID { return ura.UserID })
teams = slicesx.Map(rc.TeamAccesses, func(tra portainer.TeamResourceAccess) portainer.TeamID { return tra.TeamID })
userId, userRole, err := getCreator()
if err != nil {
log.Error().Err(err).Msgf("failed to read user when migrating to source")
return
}
// we don't want to save the ownerid if the user is admin
// this avoids admins taking ownership of a new source
if userRole == portainer.AdministratorRole {
return
}
// We also don't want to get the ownerid if the user doesn't have access to the resource anymore
userTeams, err := getCreatorMemberships(userId)
if err != nil {
log.Error().Err(err).Msgf("failed to read user %d teams when migrating source", userId)
return
}
teamIds := slicesx.Map(userTeams, func(membership portainer.TeamMembership) portainer.TeamID { return membership.TeamID })
if authorization.UserCanAccessResource(userId, teamIds, rc) {
ownerId = userId
}
return
}

View File

@@ -577,7 +577,7 @@ func (store *Store) Export(filename string) (err error) {
backup.SSLSettings = *settings
}
if s, err := store.Source().ReadAll(source.InsecureNewAdminContext()); err != nil {
if s, err := store.Source().ReadAll(); err != nil {
if !store.IsErrObjectNotFound(err) {
log.Error().Err(err).Msg("exporting Sources")
}
@@ -768,7 +768,7 @@ func (store *Store) Import(filename string) (err error) {
}
for _, v := range backup.Source {
if err := store.Source().Update(source.InsecureNewAdminContext(), v.ID, &v); err != nil {
if err := store.Source().Update(v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the source in the database")
}
}

View File

@@ -194,11 +194,11 @@ func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Cli
}
transport = &NodeNameTransport{
Transport: ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig}),
Transport: ssrf.NewTransport(tlsConfig),
}
} else {
transport = &NodeNameTransport{
Transport: ssrf.WrapTransport(&http.Transport{}),
Transport: ssrf.NewTransport(nil),
}
}

View File

@@ -66,11 +66,8 @@ func NewAzureClient() *azureClient {
func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
return &http.Client{
Transport: ssrf.WrapTransport(&http.Transport{
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
Proxy: http.ProxyFromEnvironment,
}),
Timeout: 300 * time.Second,
Transport: ssrf.NewTransport(crypto.CreateTLSConfiguration(insecureSkipVerify)),
Timeout: 300 * time.Second,
}
}

View File

@@ -118,7 +118,7 @@ func (c *gitClient) ListRefs(ctx context.Context, repositoryUrl string, opt *git
URLs: []string{repositoryUrl},
})
refs, err := rem.List(opt)
refs, err := rem.ListContext(ctx, opt)
if err != nil {
return nil, checkGitError(err)
}

View File

@@ -1,5 +0,0 @@
package sources
import "github.com/portainer/portainer/api/dataservices/source"
var adminUserContext = source.InsecureNewAdminContext()

View File

@@ -2,7 +2,6 @@ package sources
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -24,14 +23,14 @@ type RepoConfigInput struct {
}
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
func ResolveRepoConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
cfg := gittypes.RepoConfig{
ReferenceName: input.ReferenceName,
ConfigFilePath: input.ConfigFilePath,
}
if input.SourceID != 0 {
src, httpErr := ValidateGitSourceAccess(tx, userContext, input.SourceID)
src, httpErr := ValidateGitSourceAccess(tx, input.SourceID)
if httpErr != nil {
return gittypes.RepoConfig{}, httpErr
}

View File

@@ -30,9 +30,9 @@ func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) {
},
},
}
require.NoError(t, store.Source().Create(adminUserContext, src))
require.NoError(t, store.Source().Create(src))
cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
SourceID: src.ID,
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
@@ -51,7 +51,7 @@ func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://github.com/org/repo",

View File

@@ -16,8 +16,9 @@ type gitSourceStore interface {
}
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
func ValidateGitSourceAccess(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
src, err := tx.Source().Read(userContext, sourceID)
// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced.
func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
src, err := tx.Source().Read(sourceID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Source not found", err)

View File

@@ -19,9 +19,9 @@ func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"},
}
require.NoError(t, store.Source().Create(adminUserContext, src))
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, adminUserContext, src.ID)
_, httpErr := ValidateGitSourceAccess(store, src.ID)
assert.Nil(t, httpErr)
}
@@ -29,7 +29,21 @@ func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
_, httpErr := ValidateGitSourceAccess(store, adminUserContext, portainer.SourceID(999))
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999))
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
}
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceType(99), // not a git source
}
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
}

View File

@@ -5,7 +5,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
@@ -23,8 +22,6 @@ func FetchWorkflows(
) ([]Workflow, error) {
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
@@ -53,7 +50,7 @@ func FetchWorkflows(
workflowIDSet.Add(stack.WorkflowID)
}
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet)
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, workflowIDSet)
if err != nil {
return nil, err
}
@@ -116,9 +113,7 @@ func FetchSourceStats(
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) {
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
sources, err := tx.Source().ReadAll(userContext)
sources, err := tx.Source().ReadAll()
if err != nil {
return nil, nil, err
}

View File

@@ -15,11 +15,7 @@ import (
)
func adminContext() *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
return &security.RestrictedRequestContext{IsAdmin: true, UserID: 1}
}
func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
@@ -28,7 +24,7 @@ func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *por
cfg := stack.GitConfig
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: cfg}
require.NoError(t, tx.Source().Create(adminUserContext, src))
require.NoError(t, tx.Source().Create(src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID,
@@ -203,8 +199,8 @@ func TestFetchSourceStats_ReturnsAllSources(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo1"}}))
require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo2"}}))
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit}))
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
@@ -227,8 +223,8 @@ func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) {
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
require.NoError(t, tx.Source().Create(adminUserContext, src))
src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(src))
srcID = src.ID
for i := 1; i <= 2; i++ {
@@ -265,8 +261,8 @@ func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) {
var unusedID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
require.NoError(t, tx.Source().Create(adminUserContext, src))
src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(src))
unusedID = src.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})

View File

@@ -1,7 +0,0 @@
package workflows
import (
"github.com/portainer/portainer/api/dataservices/source"
)
var adminUserContext = source.InsecureNewAdminContext()

View File

@@ -20,7 +20,7 @@ type gitSourceStore interface {
// from the workflow identified by workflowID.
// Source carries the shared fields (URL, auth, TLS); ArtifactFile carries the file-specific fields (ref, path, hash).
// Returns nil, nil, nil when workflowID is 0 or no matching entry is found.
func GitSourceAndArtifactForStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) {
func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) {
if workflowID == 0 {
return nil, nil, nil
}
@@ -30,7 +30,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, userContext *dataservices.S
return nil, nil, err
}
sourceMap, err := loadWorkflowSources(tx, userContext, wf)
sourceMap, err := loadWorkflowSources(tx, wf)
if err != nil {
return nil, nil, err
}
@@ -57,7 +57,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, userContext *dataservices.S
// GitSourceAndArtifactForEdgeStack returns the git Source and the ArtifactFile matching edgeStackID.
// Returns nil, nil, nil when workflowID is 0 or no matching entry is found.
func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) {
func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) {
if workflowID == 0 {
return nil, nil, nil
}
@@ -67,7 +67,7 @@ func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext *dataservic
return nil, nil, err
}
sourceMap, err := loadWorkflowSources(tx, userContext, wf)
sourceMap, err := loadWorkflowSources(tx, wf)
if err != nil {
return nil, nil, err
}
@@ -169,15 +169,45 @@ func UpdateArtifactFileForEdgeStack(tx gitSourceStore, workflowID portainer.Work
// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg,
// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source;
// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact.
func FindOrCreateGitSource(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, src *portainer.Source) (*portainer.Source, error) {
return tx.Source().FindOrCreateGitSource(userContext, src)
func FindOrCreateGitSource(tx gitSourceStore, src *portainer.Source) (*portainer.Source, error) {
src.Git.URL = gittypes.SanitizeURL(src.Git.URL)
existing, err := tx.Source().ReadAll(func(s portainer.Source) bool {
return s.Type == portainer.SourceTypeGit &&
s.Git != nil &&
s.Git.URL == src.Git.URL &&
gitAuthMatches(s.Git.Authentication, src.Git.Authentication)
})
if err != nil {
return nil, err
}
if len(existing) > 0 {
return &existing[0], nil
}
toCreate := &portainer.Source{
Name: src.Name,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: src.Git.URL,
Authentication: src.Git.Authentication,
TLSSkipVerify: src.Git.TLSSkipVerify,
},
}
if err := tx.Source().Create(toCreate); err != nil {
return nil, err
}
return toCreate, nil
}
// SaveWorkflowGitConfig persists URL/auth/TLS on the Source and ref/path/hash on the Artifact
// matched by matchArtifact. When the URL changes, an existing or new Source is located via
// FindOrCreateGitSource and the Workflow's SourceID is updated atomically alongside the Artifact fields.
func SaveWorkflowGitConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
src, err := tx.Source().Read(userContext, oldSourceID)
func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
src, err := tx.Source().Read(oldSourceID)
if err != nil {
return fmt.Errorf("failed to read source: %w", err)
}
@@ -189,7 +219,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, userContext *dataservices.SourceSe
newSourceID := oldSourceID
if cfg.URL != src.Git.URL {
newSrc, err := FindOrCreateGitSource(tx, userContext, &portainer.Source{
newSrc, err := FindOrCreateGitSource(tx, &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
@@ -203,7 +233,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, userContext *dataservices.SourceSe
src.Git.Authentication = cfg.Authentication
src.Git.TLSSkipVerify = cfg.TLSSkipVerify
if err := tx.Source().Update(userContext, src.ID, src); err != nil {
if err := tx.Source().Update(src.ID, src); err != nil {
return fmt.Errorf("failed to update source: %w", err)
}
}
@@ -267,7 +297,7 @@ func LoadWorkflowMap(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[
// LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference,
// collecting source IDs in a single pass over the workflows.
func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) {
func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) {
wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids))
sourceIDs := make(set.Set[portainer.SourceID])
for id := range ids {
@@ -283,7 +313,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext *dataservices.Sour
}
}
srcMap, err := loadSourceMap(tx, userContext, sourceIDs)
srcMap, err := LoadSourceMap(tx, sourceIDs)
if err != nil {
return nil, nil, err
}
@@ -293,7 +323,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext *dataservices.Sour
// loadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map.
// This avoids reading the same Source record more than once when files share a SourceID.
func loadWorkflowSources(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
func loadWorkflowSources(tx gitSourceStore, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
ids := make(set.Set[portainer.SourceID])
for _, as := range wf.Artifacts {
for _, f := range as.Files {
@@ -301,22 +331,67 @@ func loadWorkflowSources(tx gitSourceStore, userContext *dataservices.SourceServ
}
}
return loadSourceMap(tx, userContext, ids)
return LoadSourceMap(tx, ids)
}
// loadSourceMap fetches sources by their IDs and returns them keyed by ID.
func loadSourceMap(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) {
sources, err := tx.Source().ReadAll(userContext, func(s portainer.Source) bool {
return ids.Contains(s.ID)
})
if err != nil {
return nil, err
}
// LoadSourceMap fetches sources by their IDs and returns them keyed by ID.
func LoadSourceMap(tx gitSourceStore, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) {
result := make(map[portainer.SourceID]portainer.Source, len(ids))
for _, src := range sources {
result[src.ID] = src
for id := range ids {
src, err := tx.Source().Read(id)
if err != nil {
return nil, err
}
result[id] = *src
}
return result, nil
}
func gitAuthMatches(a, b *gittypes.GitAuthentication) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.Username == b.Username && a.Password == b.Password
}
// ValidateUniqueSource validates there are no other sources with the same URL and credentials.
// Pass empty strings for username and password when the source has no authentication.
func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sourceID portainer.SourceID) (bool, error) {
normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url))
if err != nil {
return false, err
}
existing, err := tx.Source().ReadAll(func(s portainer.Source) bool {
if s.ID == sourceID || s.Type != portainer.SourceTypeGit || s.Git == nil {
return false
}
normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.Git.URL))
if err != nil || normalized != normalizedURL {
return false
}
existingUsername, existingPassword := gitAuthCredentials(s.Git.Authentication)
return existingUsername == username && existingPassword == password
})
if err != nil {
return false, err
}
return len(existing) == 0, nil
}
func gitAuthCredentials(auth *gittypes.GitAuthentication) (username, password string) {
if auth == nil {
return "", ""
}
return auth.Username, auth.Password
}

View File

@@ -80,7 +80,7 @@ func TestGitSourceAndArtifactForStack_ZeroWorkflowIDReturnsNil(t *testing.T) {
var file *portainer.ArtifactFile
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, 0, 1)
src, file, txErr = GitSourceAndArtifactForStack(tx, 0, 1)
return txErr
})
require.NoError(t, err)
@@ -98,7 +98,7 @@ func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T)
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(adminUserContext, gitSrc)
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -124,7 +124,7 @@ func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T)
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 42)
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 42)
return txErr
})
require.NoError(t, err)
@@ -146,7 +146,7 @@ func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T)
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -167,7 +167,43 @@ func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T)
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 99)
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 99)
return txErr
})
require.NoError(t, err)
require.Nil(t, src)
require.Nil(t, file)
}
func TestGitSourceAndArtifactForStack_NonGitSourceSkipped(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
nonGitSrc := &portainer.Source{Type: portainer.SourceType(99)}
err := tx.Source().Create(nonGitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{SourceID: nonGitSrc.ID}},
}},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
var src *portainer.Source
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 1)
return txErr
})
require.NoError(t, err)
@@ -183,7 +219,7 @@ func TestGitSourceAndArtifactForEdgeStack_ZeroWorkflowIDReturnsNil(t *testing.T)
var file *portainer.ArtifactFile
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, 0, 1)
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, 0, 1)
return txErr
})
require.NoError(t, err)
@@ -201,7 +237,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/edge-repo"},
}
err := tx.Source().Create(adminUserContext, gitSrc)
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -226,7 +262,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 5)
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 5)
return txErr
})
require.NoError(t, err)
@@ -244,7 +280,7 @@ func TestUpdateArtifactFileForStack_NoMatchingArtifactIsNoOp(t *testing.T) {
var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
@@ -282,7 +318,7 @@ func TestUpdateArtifactFileForStack_AppliesFnAndPersists(t *testing.T) {
var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
@@ -320,7 +356,7 @@ func TestUpdateArtifactFileForEdgeStack_AppliesFnAndPersists(t *testing.T) {
var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
@@ -357,7 +393,7 @@ func TestFindOrCreateGitSource_CreatesNewSource(t *testing.T) {
var src *portainer.Source
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
src, txErr = FindOrCreateGitSource(tx, &portainer.Source{
Name: "my-repo",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
@@ -377,7 +413,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.
_, store := datastore.MustNewTestStore(t, false, true)
makeSource := func(tx dataservices.DataStoreTx) (*portainer.Source, error) {
return FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
return FindOrCreateGitSource(tx, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -410,7 +446,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.
require.NoError(t, err)
require.Equal(t, firstID, secondID)
sources, err := store.Source().ReadAll(adminUserContext)
sources, err := store.Source().ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1)
}
@@ -420,7 +456,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, true)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
_, txErr := FindOrCreateGitSource(tx, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -432,7 +468,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
_, txErr := FindOrCreateGitSource(tx, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -443,7 +479,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
})
require.NoError(t, err)
sources, err := store.Source().ReadAll(adminUserContext)
sources, err := store.Source().ReadAll()
require.NoError(t, err)
require.Len(t, sources, 2)
}
@@ -467,7 +503,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
},
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
@@ -503,7 +539,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
}
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, newCfg)
})
@@ -516,7 +552,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash)
require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID)
src, err := store.Source().Read(adminUserContext, sourceID)
src, err := store.Source().Read(sourceID)
require.NoError(t, err)
require.Equal(t, "new-user", src.Git.Authentication.Username)
require.Equal(t, "new-pass", src.Git.Authentication.Password)
@@ -535,7 +571,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
oldSourceID = src.ID
@@ -556,7 +592,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/new-repo"}
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, oldSourceID, newCfg)
})
@@ -567,7 +603,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
newSourceID := wf.Artifacts[0].Files[0].SourceID
require.NotEqual(t, oldSourceID, newSourceID)
newSrc, err := store.Source().Read(adminUserContext, newSourceID)
newSrc, err := store.Source().Read(newSourceID)
require.NoError(t, err)
require.Equal(t, "https://github.com/example/new-repo", newSrc.Git.URL)
}
@@ -584,7 +620,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"},
}
err := tx.Source().Create(adminUserContext, old)
err := tx.Source().Create(old)
require.NoError(t, err)
oldSourceID = old.ID
@@ -592,7 +628,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
}
err = tx.Source().Create(adminUserContext, existing)
err = tx.Source().Create(existing)
require.NoError(t, err)
existingSourceID = existing.ID
@@ -613,7 +649,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"}
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, oldSourceID, newCfg)
})
@@ -623,11 +659,46 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
require.NoError(t, err)
require.Equal(t, existingSourceID, wf.Artifacts[0].Files[0].SourceID)
sources, err := store.Source().ReadAll(adminUserContext)
sources, err := store.Source().ReadAll()
require.NoError(t, err)
require.Len(t, sources, 2)
}
func TestSaveWorkflowGitConfig_NilGitConfigReturnsError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{SourceID: sourceID}},
}},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, &gittypes.RepoConfig{URL: "https://github.com/example/repo"})
})
require.Error(t, err)
}
func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
@@ -640,7 +711,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
@@ -665,7 +736,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -688,7 +759,7 @@ func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *test
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -733,7 +804,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(adminUserContext, old)
err := tx.Source().Create(old)
require.NoError(t, err)
oldSourceID = old.ID
@@ -747,7 +818,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
},
},
}
err = tx.Source().Create(adminUserContext, selected)
err = tx.Source().Create(selected)
require.NoError(t, err)
newSourceID = selected.ID
@@ -790,7 +861,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash)
// The selected source's git config must be left untouched.
selected, err := store.Source().Read(adminUserContext, newSourceID)
selected, err := store.Source().Read(newSourceID)
require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", selected.Git.URL)
require.Equal(t, "selected-user", selected.Git.Authentication.Username)
@@ -805,7 +876,7 @@ func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t *
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -848,7 +919,7 @@ func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
@@ -900,7 +971,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
}
err := tx.Source().Create(adminUserContext, gitSrc)
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -921,7 +992,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 20)
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 20)
return txErr
})
require.NoError(t, err)
@@ -941,7 +1012,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"},
}
err := tx.Source().Create(adminUserContext, gitSrc)
err := tx.Source().Create(gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -962,7 +1033,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 20)
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 20)
return txErr
})
require.NoError(t, err)
@@ -999,7 +1070,7 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
var src *portainer.Source
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
src, txErr = FindOrCreateGitSource(tx, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://user:secret@github.com/example/repo",
@@ -1010,3 +1081,97 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
}
func newSourceWithAuth(url, username, password string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: url,
Authentication: &gittypes.GitAuthentication{
Username: username,
Password: password,
},
},
}
}
func newAuthlessSource(url string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: url},
}
}
func validateUniqueSourceInStore(t *testing.T, store *datastore.Store, url, username, password string, sourceID portainer.SourceID) bool {
t.Helper()
var isUnique bool
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
isUnique, err = ValidateUniqueSource(tx, url, username, password, sourceID)
return err
}))
return isUnique
}
func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
}
func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
}
func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
if err := tx.Source().Create(src); err != nil {
return err
}
srcID = src.ID
return nil
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
}

View File

@@ -125,9 +125,9 @@ func ExecutePingOperation(host string, tlsConfiguration portainer.TLSConfigurati
}
scheme = "https"
transport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
transport = ssrf.NewTransport(tlsConfig)
} else {
transport = ssrf.WrapTransport(&http.Transport{})
transport = ssrf.NewTransport(nil)
}
client := &http.Client{

View File

@@ -9,7 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
@@ -33,9 +32,9 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ
return httperror.BadRequest("Invalid query parameter: method", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
}
customTemplate, err := handler.createCustomTemplate(method, r)
@@ -43,16 +42,16 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to create custom template", err)
}
customTemplate.CreatedByUserID = securityContext.UserID
customTemplate.CreatedByUserID = tokenData.ID
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return createCustomTemplateTx(tx, customTemplate, securityContext)
return createCustomTemplateTx(tx, customTemplate, tokenData.ID)
})
return response.TxResponse(w, customTemplate, err)
}
func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, sc *security.RestrictedRequestContext) error {
func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, userID portainer.UserID) error {
existingTemplates, err := tx.CustomTemplate().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
@@ -68,16 +67,14 @@ func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portain
return httperror.InternalServerError("Unable to create custom template", err)
}
resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, sc.UserID)
resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, userID)
if err := tx.ResourceControl().Create(resourceControl); err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err)
}
customTemplate.ResourceControl = resourceControl
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
populateGitConfig(tx, customTemplate)
return nil
}
@@ -285,11 +282,6 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
return nil, err
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier()
customTemplate := &portainer.CustomTemplate{
ID: portainer.CustomTemplateID(customTemplateID),
@@ -310,9 +302,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
projectPath := getProjectPath()
customTemplate.ProjectPath = projectPath
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository,
@@ -337,7 +327,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
sourceID := payload.SourceID
if sourceID == 0 {
src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{

View File

@@ -30,14 +30,7 @@ func createTemplateRequest(t *testing.T, method string, payload any, userID port
r.Header.Set("Content-Type", "application/json")
r = mux.SetURLVars(r, map[string]string{"method": method})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role})
r = r.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: role == portainer.AdministratorRole,
User: &portainer.User{ID: userID, Role: role},
})
return r.WithContext(ctx)
return r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role}))
}
func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) {
@@ -279,13 +272,7 @@ func TestCustomTemplateCreate_FromFileUpload_Success(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -474,13 +461,7 @@ func TestCustomTemplateCreate_FromFileUpload_MissingTitle(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -516,13 +497,7 @@ func TestCustomTemplateCreate_FromFileUpload_MissingDescription(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -555,13 +530,7 @@ func TestCustomTemplateCreate_FromFileUpload_MissingFile(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -600,13 +569,7 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidType(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -645,13 +608,7 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidPlatform(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -693,13 +650,7 @@ func TestCustomTemplateCreate_FromFileUpload_NoteWithImage(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -738,13 +689,7 @@ func TestCustomTemplateCreate_FromFileUpload_KubernetesIgnoresPlatform(t *testin
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -803,13 +748,7 @@ func TestCustomTemplateCreate_FromFileUpload_Variables(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -865,13 +804,7 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidVariables(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"})
ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -933,7 +866,7 @@ func TestCustomTemplateCreate_FromRepository_Success(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, stored.Artifact)
src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID)
src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID)
require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)
@@ -970,7 +903,7 @@ func TestCustomTemplateCreate_FromRepository_DeduplicatesSource(t *testing.T) {
require.Nil(t, herr)
err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
sources, err := tx.Source().ReadAll(adminUserContext)
sources, err := tx.Source().ReadAll()
require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source")
@@ -1119,7 +1052,7 @@ func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T)
URL: "https://github.com/example/repo",
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return nil

View File

@@ -153,7 +153,7 @@ func TestCustomTemplateFile_GitTemplate(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent))

View File

@@ -7,10 +7,7 @@ import (
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -52,23 +49,9 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
file := customTemplate.Artifact.Files[0]
securityContext, err := security.RetrieveRestrictedRequestContext(r)
src, err := handler.DataStore.Source().Read(file.SourceID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var src *portainer.Source
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
src, err = tx.Source().Read(userContext, file.SourceID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve git source for custom template", err)
}
return nil
}); err != nil {
return response.TxErrorResponse(err)
return httperror.InternalServerError("Unable to retrieve git source for custom template", err)
}
if src.Git == nil {

View File

@@ -174,14 +174,13 @@ func Test_customTemplateGitFetch(t *testing.T) {
require.NoError(t, err, "error to get working directory")
src := &portainer.Source{
ID: 1,
Type: portainer.SourceTypeGit,
Public: true,
ID: 1,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err = store.Source().Create(adminUserContext, src)
err = store.Source().Create(src)
require.NoError(t, err, "error creating source")
const configFilePath = "test-config-path.txt"
@@ -337,3 +336,31 @@ func TestCustomTemplateGitFetch_EmptySourceIDsReturnsBadRequest(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestCustomTemplateGitFetch_SourceWithNilGitConfigReturnsInternalError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := store.Source().Create(src)
require.NoError(t, err)
template := &portainer.CustomTemplate{
ID: 1,
Title: "nil-git-config",
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
},
}
err = store.CustomTemplateService.Create(template)
require.NoError(t, err)
h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusInternalServerError, rr.Code)
}

View File

@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
@@ -71,8 +70,7 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
populateGitConfig(tx, customTemplate)
return nil
})

View File

@@ -174,7 +174,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
TLSSkipVerify: true,
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -194,7 +194,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/10", nil)
r = mux.SetURLVars(r, map[string]string{"id": "10"})
ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})
ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
herr := handler.customTemplateInspect(rr, r)

View File

@@ -5,8 +5,6 @@ import (
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
@@ -39,54 +37,47 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
edge := retrieveEdgeParam(r)
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
}
resourceControls, err := handler.DataStore.ResourceControl().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve resource controls from the database", err)
}
customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var customTemplates []portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplates, err = tx.CustomTemplate().ReadAll()
if !securityContext.IsAdmin {
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
return httperror.InternalServerError("Unable to retrieve user information from the database", err)
}
resourceControls, err := tx.ResourceControl().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve resource controls from the database", err)
}
userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships)
customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls)
customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs)
}
if !securityContext.IsAdmin {
user, err := tx.User().Read(securityContext.UserID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user information from the database", err)
}
customTemplates = filterByType(customTemplates, templateTypes)
userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships)
if edge != nil {
customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge
})
}
customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs)
}
for i := range customTemplates {
populateGitConfig(handler.DataStore, &customTemplates[i])
}
customTemplates = filterByType(customTemplates, templateTypes)
if edge != nil {
customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge
})
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
for i := range customTemplates {
populateGitConfig(tx, userContext, &customTemplates[i])
}
return nil
})
return response.TxResponse(w, customTemplates, err)
return response.JSON(w, customTemplates)
}
func retrieveEdgeParam(r *http.Request) *bool {

View File

@@ -28,7 +28,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) {
TLSSkipVerify: true,
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
@@ -48,7 +48,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) {
}))
r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}))
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, r)
@@ -97,7 +97,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) {
},
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
@@ -111,7 +111,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) {
}))
r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}))
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, r)

View File

@@ -9,7 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
@@ -183,10 +182,8 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.IsComposeFormat = payload.IsComposeFormat
customTemplate.EdgeTemplate = payload.EdgeTemplate
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 || payload.RepositoryURL != "" {
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository,
@@ -234,7 +231,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
sourceID := payload.SourceID
if sourceID == 0 {
src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
@@ -274,8 +271,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist custom template changes inside the database", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
populateGitConfig(tx, customTemplate)
return nil
})

View File

@@ -27,14 +27,6 @@ func updateTemplateRequest(t *testing.T, templateID string, payload any, ctx *se
r.Header.Set("Content-Type", "application/json")
r = mux.SetURLVars(r, map[string]string{"id": templateID})
if ctx.User == nil {
role := portainer.StandardUserRole
if ctx.IsAdmin {
role = portainer.AdministratorRole
}
ctx.User = &portainer.User{ID: ctx.UserID, Role: role}
}
return r.WithContext(security.StoreRestrictedRequestContext(r, ctx))
}
@@ -484,7 +476,7 @@ func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) {
URL: "https://github.com/example/repo",
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return nil
@@ -638,7 +630,7 @@ func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, stored.Artifact)
src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID)
src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID)
require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL)

View File

@@ -1,5 +0,0 @@
package customtemplates
import "github.com/portainer/portainer/api/dataservices/source"
var adminUserContext = source.InsecureNewAdminContext()

View File

@@ -8,14 +8,14 @@ import (
"github.com/portainer/portainer/api/dataservices"
)
func populateGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, template *portainer.CustomTemplate) {
func populateGitConfig(tx dataservices.DataStoreTx, template *portainer.CustomTemplate) {
if template.Artifact == nil || len(template.Artifact.Files) == 0 {
return
}
file := template.Artifact.Files[0]
src, err := tx.Source().Read(userContext, file.SourceID)
src, err := tx.Source().Read(file.SourceID)
if err != nil || src.Git == nil {
return
}

View File

@@ -19,8 +19,7 @@ func TestPopulateGitConfig_NilArtifactIsNoOp(t *testing.T) {
template := &portainer.CustomTemplate{ID: 1}
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, adminUserContext, template)
populateGitConfig(tx, template)
return nil
})
@@ -41,7 +40,39 @@ func TestPopulateGitConfig_EmptySourceIDsIsNoOp(t *testing.T) {
}
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, adminUserContext, template)
populateGitConfig(tx, template)
return nil
})
require.NoError(t, err)
require.Nil(t, template.GitConfig)
}
func TestPopulateGitConfig_SourceWithNilGitConfigIsNoOp(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return nil
})
require.NoError(t, err)
template := &portainer.CustomTemplate{
ID: 1,
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{SourceID: srcID}},
},
}
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template)
return nil
})
@@ -63,7 +94,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) {
TLSSkipVerify: true,
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -84,7 +115,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) {
}
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, adminUserContext, template)
populateGitConfig(tx, template)
return nil
})
@@ -114,7 +145,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) {
},
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -130,7 +161,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) {
}
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, adminUserContext, template)
populateGitConfig(tx, template)
return nil
})

View File

@@ -0,0 +1,95 @@
package docker
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/datastore"
dockerdomain "github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// unreachableDockerURL points the test environment at a port that refuses connections, so an
// authorized caller fails fast when the handler builds a docker client rather than blocking on a
// real daemon. The authorization middleware runs before this, which is what these tests assert.
const unreachableDockerURL = "tcp://127.0.0.1:1"
func newDashboardAuthTestHandler(t *testing.T) (*Handler, *jwt.Service, *datastore.Store) {
t.Helper()
fips.InitFIPS(false)
_, store := datastore.MustNewTestStore(t, true, true)
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{
ID: 1, Name: "docker-env", Type: portainer.DockerEnvironment, URL: unreachableDockerURL,
}))
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
bouncer := security.NewRequestBouncer(t.Context(), store, jwtService, apikey.NewAPIKeyService(nil, nil))
factory := dockerclient.NewClientFactory(nil, nil)
authorizationService := authorization.NewService(store)
containerService := dockerdomain.NewContainerService(factory, store)
handler := NewHandler(bouncer, authorizationService, store, factory, containerService)
return handler, jwtService, store
}
func dashboardRequest(t *testing.T, handler *Handler, jwtService *jwt.Service, user *portainer.User) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/docker/1/dashboard", nil)
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
require.NoError(t, err)
testhelpers.AddTestSecurityCookie(req, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
return rr
}
// TestEndpointAuthorization_DeniedUser_Returns403 verifies that the docker /dashboard
// route rejects users with no access policy for the target environment (R8S-1057).
func TestEndpointAuthorization_DeniedUser_Returns403(t *testing.T) {
handler, jwtService, store := newDashboardAuthTestHandler(t)
noAccessUser := &portainer.User{
Username: "no-access",
Role: portainer.StandardUserRole,
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
}
require.NoError(t, store.User().Create(noAccessUser))
// A standard user with no access policy must be rejected before the dashboard handler
// builds a docker client for the environment.
rr := dashboardRequest(t, handler, jwtService, noAccessUser)
assert.Equal(t, http.StatusForbidden, rr.Code)
}
// TestEndpointAuthorization_AuthorizedUser_NotForbidden verifies that the docker /dashboard
// route lets an authorized caller through the authorization middleware (R8S-1057). The request
// fails later when the handler cannot reach the docker daemon, but it must not be rejected with 403.
func TestEndpointAuthorization_AuthorizedUser_NotForbidden(t *testing.T) {
handler, jwtService, store := newDashboardAuthTestHandler(t)
adminUser := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
require.NoError(t, store.User().Create(adminUser))
rr := dashboardRequest(t, handler, jwtService, adminUser)
assert.NotEqual(t, http.StatusForbidden, rr.Code)
}

View File

@@ -44,7 +44,9 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Use(bouncer.AuthenticatedAccess)
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"), dockerOnlyMiddleware)
endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.dashboard)).Methods(http.MethodGet)
// /dashboard is the only route on this router without its own endpoint authorization;
// the containers/images sub-routers already apply CheckEndpointAuthorization.
endpointRouter.Handle("/dashboard", middlewares.CheckEndpointAuthorization(bouncer)(httperror.LoggerHandler(h.dashboard))).Methods(http.MethodGet)
containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)

View File

@@ -7,15 +7,12 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/edge"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate"
@@ -127,13 +124,7 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
return stack, nil
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
repoConfig, httpErr := sources.ResolveRepoConfig(tx, userContext, sources.RepoConfigInput{
repoConfig, httpErr := sources.ResolveRepoConfig(tx, sources.RepoConfigInput{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository,

View File

@@ -135,6 +135,10 @@ func (handler *Handler) parseHeaders(r *http.Request, endpoint *portainer.Endpoi
version := r.Header.Get(portainer.PortainerAgentHeader)
endpoint.Agent.Version = version
if gpuOperatorHeader := r.Header.Get(portainer.HTTPResponseAgentGPUOperator); gpuOperatorHeader != "" {
endpoint.Kubernetes.Flags.GPUOperator = gpuOperatorHeader == "true"
}
return nil
}

View File

@@ -7,10 +7,8 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -89,14 +87,8 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
password := payload.Password
tlsSkipVerify := payload.TLSSkipVerify
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, userContext, payload.SourceID)
src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, payload.SourceID)
if httpErr != nil {
return httpErr
}

View File

@@ -7,9 +7,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/gitops/workflows"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -22,16 +21,8 @@ type GitAuthenticationPayload struct {
Password string `json:"password"`
}
type SourceAccessControlPayload struct {
Public bool `json:"public" example:"true"`
AdministratorsOnly bool `json:"administratorsOnly" example:"true"`
UserAccesses []portainer.UserID `json:"userAccesses"`
TeamAccesses []portainer.TeamID `json:"teamAccesses"`
}
// GitSourceCreatePayload holds the parameters for creating a git-backed source
type GitSourceCreatePayload struct {
SourceAccessControlPayload
Name string `json:"name"`
URL string `json:"url" validate:"required"`
TLSSkipVerify bool `json:"tlsSkipVerify"`
@@ -50,7 +41,7 @@ func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error {
// @id GitOpsSourcesCreateGit
// @summary Create a Git source
// @description Creates a new GitOps source backed by a Git repository.
// @description **Access policy**: authenticated
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -70,20 +61,26 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
return httperror.BadRequest("Invalid request payload", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
src, err := BuildGitSource(payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
username, password := "", ""
if payload.Authentication != nil {
username = payload.Authentication.Username
password = payload.Authentication.Password
}
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
return tx.Source().Create(userContext, src)
}); errors.Is(err, source.ErrDuplicateSource) {
if isUnique, err := workflows.ValidateUniqueSource(tx, payload.URL, username, password, 0); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Create(src)
}); errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil {
return httperror.InternalServerError("Unable to create source", err)
@@ -102,7 +99,8 @@ func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) {
return src, nil
}
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS, accesses) without authentication.
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without
// authentication.
func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
name := payload.Name
if strings.TrimSpace(name) == "" {
@@ -116,10 +114,6 @@ func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
URL: payload.URL,
TLSSkipVerify: payload.TLSSkipVerify,
},
UserAccesses: payload.UserAccesses,
TeamAccesses: payload.TeamAccesses,
Public: payload.Public,
AdministratorsOnly: payload.AdministratorsOnly,
}
}

View File

@@ -97,7 +97,7 @@ func TestGitSourceCreate_Success(t *testing.T) {
require.Equal(t, portainer.SourceTypeGit, src.Type)
require.NotZero(t, src.ID)
require.NotNil(t, src.Git)
require.Equal(t, "https://github.com/org/repo", src.Git.URL)
require.Equal(t, "https://github.com/org/repo.git", src.Git.URL)
}
func TestGitSourceCreate_SanitizesCredentials(t *testing.T) {

View File

@@ -8,8 +8,6 @@ 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/dataservices/source"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -20,7 +18,7 @@ var ErrSourceInUse = errors.New("source is used by one or more workflows or cust
// @id GitOpsSourcesDelete
// @summary Delete a source
// @description Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template.
// @description **Access policy**: authenticated
// @description **Access policy**: admin
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -38,15 +36,8 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro
return httperror.BadRequest("Invalid source identifier route variable", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if exists, err := tx.Source().Exists(userContext, portainer.SourceID(sourceID)); err != nil {
if exists, err := tx.Source().Exists(portainer.SourceID(sourceID)); err != nil {
return err
} else if !exists {
return dserrors.ErrObjectNotFound
@@ -80,13 +71,11 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro
return ErrSourceInUse
}
return tx.Source().Delete(userContext, portainer.SourceID(sourceID))
return tx.Source().Delete(portainer.SourceID(sourceID))
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrSourceInUse) {
return httperror.Conflict("Source is used by one or more workflows or custom templates", err)
} else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to delete source", err)
} else if err != nil {
return httperror.InternalServerError("Unable to delete source", err)
}

View File

@@ -8,7 +8,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/require"
)
@@ -20,8 +19,8 @@ func TestSourceDelete_Success(t *testing.T) {
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -58,8 +57,8 @@ func TestSourceDelete_InUse(t *testing.T) {
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -100,8 +99,8 @@ func TestSourceDelete_InUseByCustomTemplate(t *testing.T) {
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID

View File

@@ -5,9 +5,9 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
)
// FetchSourceWorkflows returns the workflows and stats for a single source.
@@ -27,18 +27,7 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
return nil, ce.SourceStats{}, nil
}
wfIDSet := make(map[portainer.WorkflowID]struct{}, len(wfs))
artifactByStack := make(map[portainer.StackID]portainer.ArtifactFile)
for _, wf := range wfs {
wfIDSet[wf.ID] = struct{}{}
for _, art := range wf.Artifacts {
for _, f := range art.Files {
if f.SourceID == src.ID {
artifactByStack[art.StackID] = f
}
}
}
}
wfIDSet := set.ToSet(slicesx.Map(wfs, func(wf portainer.Workflow) portainer.WorkflowID { return wf.ID }))
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
_, ok := wfIDSet[s.WorkflowID]
@@ -52,32 +41,16 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
items := make([]ce.Workflow, 0, len(stacks))
stats := ce.SourceStats{EndpointIDs: set.Set[portainer.EndpointID]{}}
for _, s := range stacks {
gitCfg := gitConfigForArtifact(src.Git, artifactByStack[s.ID])
items = append(items, ce.MapStackToWorkflow(s, gitCfg, unknown, unknown))
for _, stacks := range stacks {
items = append(items, ce.MapStackToWorkflow(stacks, src.Git, unknown, unknown))
stats.WorkflowCount++
if s.EndpointID != 0 {
stats.EndpointIDs.Add(s.EndpointID)
if stacks.EndpointID != 0 {
stats.EndpointIDs.Add(stacks.EndpointID)
}
if lastSync := ce.StackLastSyncDate(s); lastSync > stats.LastSync {
if lastSync := ce.StackLastSyncDate(stacks); lastSync > stats.LastSync {
stats.LastSync = lastSync
}
}
return items, stats, nil
}
func gitConfigForArtifact(src *gittypes.RepoConfig, af portainer.ArtifactFile) *gittypes.RepoConfig {
if src == nil {
return nil
}
return &gittypes.RepoConfig{
URL: src.URL,
Authentication: src.Authentication,
TLSSkipVerify: src.TLSSkipVerify,
ReferenceName: af.Ref,
ConfigFilePath: af.Path,
ConfigHash: af.Hash,
}
}

View File

@@ -1,15 +1,12 @@
package sources
import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
sourceDS "github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -20,6 +17,7 @@ type gitAuthInfo struct {
}
type connectionInfo struct {
ConfigFilePath string `json:"configFilePath"`
TLSSkipVerify bool `json:"tlsSkipVerify"`
Authentication *gitAuthInfo `json:"authentication,omitempty"`
}
@@ -29,19 +27,12 @@ type AutoUpdateInfo struct {
FetchInterval string `json:"fetchInterval,omitempty"`
}
type SourceAccess struct {
Public bool `json:"public,omitempty"`
Users []portainer.UserID `json:"users,omitempty"`
Teams []portainer.TeamID `json:"teams,omitempty"`
}
// SourceDetail extends Source with connection settings and linked workflows.
type SourceDetail struct {
Source
Connection connectionInfo `json:"connection" validate:"required"`
AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []workflows.Workflow `json:"workflows"`
Access SourceAccess `json:"access"`
}
// @id GitOpsSourceGet
@@ -65,11 +56,6 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
return httperror.BadRequest("Invalid source identifier route variable", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
sourceID := portainer.SourceID(srcID)
var source *portainer.Source
@@ -78,8 +64,7 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := sourceDS.NewUserContext(securityContext.User, securityContext.UserMemberships)
source, err = tx.Source().Read(userContext, sourceID)
source, err = tx.Source().Read(sourceID)
if err != nil {
return err
}
@@ -90,19 +75,15 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
if h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Source not found", err)
} else if errors.Is(err, sourceDS.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to retrieve source", err)
} else if err != nil {
return httperror.InternalServerError("Unable to retrieve source", err)
}
access := BuildSourceAccess(source)
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs, access)
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs)
return response.JSON(w, detail)
}
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow, access SourceAccess) SourceDetail {
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail {
var autoUpdate *AutoUpdateInfo
if len(sourceWfs) > 0 {
autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
@@ -113,29 +94,6 @@ func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []
Connection: buildConnectionInfo(cfg),
AutoUpdate: autoUpdate,
Workflows: redactWorkflowCredentials(sourceWfs),
Access: access,
}
}
func BuildSourceAccess(source *portainer.Source) SourceAccess {
if source == nil {
return SourceAccess{}
}
if source.AdministratorsOnly {
return SourceAccess{}
}
if source.Public {
return SourceAccess{
Public: true,
}
}
return SourceAccess{
Public: source.Public,
Users: source.UserAccesses,
Teams: source.TeamAccesses,
}
}
@@ -144,6 +102,7 @@ func buildConnectionInfo(cfg *gittypes.RepoConfig) connectionInfo {
return connectionInfo{}
}
return connectionInfo{
ConfigFilePath: cfg.ConfigFilePath,
TLSSkipVerify: cfg.TLSSkipVerify,
Authentication: buildGitAuthInfo(cfg.Authentication),
}

View File

@@ -56,11 +56,10 @@ func TestGetSource_ReturnsDetail(t *testing.T) {
assert.Equal(t, srcID, detail.ID)
assert.Equal(t, "repo", detail.Name)
assert.Equal(t, 1, detail.UsedBy)
assert.Equal(t, "docker-compose.yml", detail.Connection.ConfigFilePath)
assert.True(t, detail.Connection.TLSSkipVerify)
require.Len(t, detail.Workflows, 1)
assert.Equal(t, "my-stack", detail.Workflows[0].Name)
require.NotNil(t, detail.Workflows[0].GitConfig)
assert.Equal(t, "docker-compose.yml", detail.Workflows[0].GitConfig.ConfigFilePath)
}
func TestGetSource_RedactsCredentials(t *testing.T) {

View File

@@ -42,15 +42,13 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet)
authenticatedRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost)
authenticatedRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete)
authenticatedRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost)
adminRouter := h.PathPrefix("/gitops/sources").Subrouter()
adminRouter.Use(bouncer.AdminAccess)
adminRouter.Handle("/{id}/access", httperror.LoggerHandler(h.gitSourceUpdateAccess)).Methods(http.MethodPut)
adminRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost)
adminRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete)
adminRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost)
return h
}

View File

@@ -9,7 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -18,8 +17,6 @@ import (
"github.com/stretchr/testify/require"
)
var adminUserContext = source.InsecureNewAdminContext()
// createGitWorkflow creates a Source and Workflow for the given config and
// wires them up by setting stack.WorkflowID before creating the stack.
func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack, cfg *gittypes.RepoConfig) portainer.SourceID {
@@ -28,13 +25,9 @@ func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portain
src := &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: cfg.URL,
Authentication: cfg.Authentication,
TLSSkipVerify: cfg.TLSSkipVerify,
},
Git: cfg,
}
require.NoError(t, tx.Source().Create(adminUserContext, src))
require.NoError(t, tx.Source().Create(src))
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
@@ -58,19 +51,13 @@ func newTestHandler(t *testing.T, store dataservices.DataStore) *Handler {
return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil)
}
func adminRestrictedContext(userID portainer.UserID) *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: true,
User: &portainer.User{ID: userID, Role: portainer.AdministratorRole},
}
}
func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -78,7 +65,9 @@ func buildGetReq(t *testing.T, userID portainer.UserID, id string) *http.Request
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -111,7 +100,9 @@ func buildCreateReq(t *testing.T, userID portainer.UserID, body []byte) *http.Re
req := httptest.NewRequest(http.MethodPost, "/gitops/sources/git", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -120,7 +111,9 @@ func buildUpdateReq(t *testing.T, userID portainer.UserID, id int, body []byte)
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/gitops/sources/%d", id), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -128,7 +121,9 @@ func buildDeleteReq(t *testing.T, userID portainer.UserID, id int) *http.Request
t.Helper()
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/gitops/sources/%d", id), nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -136,7 +131,9 @@ func buildSummaryReq(t *testing.T, userID portainer.UserID) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources/summary", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -145,7 +142,9 @@ func buildUpdateReqWithRawID(t *testing.T, userID portainer.UserID, id string, b
req := httptest.NewRequest(http.MethodPut, "/gitops/sources/"+id, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}
@@ -153,6 +152,8 @@ func buildDeleteReqWithRawID(t *testing.T, userID portainer.UserID, id string) *
t.Helper()
req := httptest.NewRequest(http.MethodDelete, "/gitops/sources/"+id, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
return req
}

View File

@@ -9,7 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/gitops/workflows"
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/http/utils/filters"
"github.com/portainer/portainer/api/slicesx"
@@ -56,7 +56,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle
}
if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" {
s, err := workflows.ParseStatus(status)
s, err := ceWorkflows.ParseStatus(status)
if err != nil {
return httperror.BadRequest("Invalid status parameter", err)
}
@@ -111,11 +111,11 @@ func cacheKey(sc *security.RestrictedRequestContext) string {
func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) {
var allSrcs []portainer.Source
var stats map[portainer.SourceID]workflows.SourceStats
var stats map[portainer.SourceID]ceWorkflows.SourceStats
if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
allSrcs, stats, err = workflows.FetchSourceStats(tx, h.k8sFactory, sc)
allSrcs, stats, err = ceWorkflows.FetchSourceStats(tx, h.k8sFactory, sc)
return err
}); err != nil {
return nil, err
@@ -123,12 +123,12 @@ func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedReque
result := make([]Source, 0, len(allSrcs))
for _, src := range allSrcs {
stat, ok := stats[src.ID]
if !ok {
stat = workflows.SourceStats{}
s, accessible := stats[src.ID]
if !accessible && !sc.IsAdmin {
continue
}
result = append(result, h.buildSource(ctx, &src, stat))
result = append(result, h.buildSource(ctx, &src, s))
}
return result, nil

View File

@@ -20,7 +20,7 @@ func TestSourcesList_GroupsByURLAndCredentials(t *testing.T) {
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
cfg := gitCfg("https://github.com/org/repo")
src := &portainer.Source{Name: "repo", Type: portainer.SourceTypeGit, Git: cfg}
require.NoError(t, tx.Source().Create(adminUserContext, src))
require.NoError(t, tx.Source().Create(src))
wfA := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
require.NoError(t, tx.Workflow().Create(wfA))

View File

@@ -8,9 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -19,7 +17,7 @@ import (
// @id GitOpsSourcesTestById
// @summary Test the connection of a stored source
// @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration.
// @description **Access policy**: authenticated
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -39,11 +37,6 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Invalid source identifier route variable", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var payload GitSourceUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil && !errors.Is(err, io.EOF) {
return httperror.BadRequest("Invalid request payload", err)
@@ -51,13 +44,10 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *
var src *portainer.Source
if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
src, err = tx.Source().Read(userContext, portainer.SourceID(sourceID))
src, err = tx.Source().Read(portainer.SourceID(sourceID))
return err
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to retrieve source", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find source", err)
}
@@ -85,7 +75,7 @@ type ConnectionTestResult struct {
// @id GitOpsSourcesTest
// @summary Test a Git source connection
// @description Tests connectivity for Git connection details that have not been persisted yet.
// @description **Access policy**: authenticated
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt

View File

@@ -1,7 +1,6 @@
package sources
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -9,7 +8,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows"
"github.com/segmentio/encoding/json"
@@ -40,9 +38,10 @@ func TestSourcesSummary_CountsByStatus(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
// With nil gitService and nil GitConfig, all sources get StatusUnknown.
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for idx, name := range []string{"source-a", "source-b", "source-c"} {
err := tx.Source().Create(adminUserContext, &portainer.Source{Name: name, Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: fmt.Sprintf("http://github.com/org/repo%d", idx)}})
for _, name := range []string{"source-a", "source-b", "source-c"} {
err := tx.Source().Create(&portainer.Source{Name: name, Type: portainer.SourceTypeGit})
require.NoError(t, err)
}

View File

@@ -1,101 +0,0 @@
package sources
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type SourceAccessUpdatePayload struct {
Public bool `json:"public"`
Users []portainer.UserID `json:"users,omitempty"`
Teams []portainer.TeamID `json:"teams,omitempty"`
}
// @id GitOpsSourcesUpdateAccess
// @summary Update a GitOps source's access control
// @description Updates the access control settings for an existing GitOps source.
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "Source identifier"
// @param body body SourceAccessUpdatePayload true "Source access control"
// @success 200 {object} portainer.Source
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 404 "Source not found"
// @failure 500 "Server error"
// @router /gitops/sources/{id}/access [put]
func (h *Handler) gitSourceUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid source identifier route variable", err)
}
var payload SourceAccessUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
sourceID := portainer.SourceID(id)
var src *portainer.Source
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if src, err = tx.Source().Read(userContext, sourceID); err != nil {
return err
}
ApplySourceAccessChanges(src, payload)
return tx.Source().Update(userContext, src.ID, src)
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if err != nil {
return httperror.InternalServerError("Unable to update source access", err)
}
return response.JSON(w, src)
}
// Validate implements the portainer.Validatable interface
func (payload *SourceAccessUpdatePayload) Validate(_ *http.Request) error {
return nil
}
// ApplySourceAccessChanges applies the payload access changes to the source in place.
func ApplySourceAccessChanges(src *portainer.Source, payload SourceAccessUpdatePayload) {
src.Public = payload.Public
if payload.Public {
src.AdministratorsOnly = false
src.UserAccesses = []portainer.UserID{}
src.TeamAccesses = []portainer.TeamID{}
} else if len(payload.Users) == 0 && len(payload.Teams) == 0 {
src.AdministratorsOnly = true
src.UserAccesses = []portainer.UserID{}
src.TeamAccesses = []portainer.TeamID{}
} else {
src.AdministratorsOnly = false
src.UserAccesses = payload.Users
src.TeamAccesses = payload.Teams
}
}

View File

@@ -7,9 +7,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/gitops/workflows"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -17,7 +16,8 @@ import (
)
var (
ErrNotGitSource = errors.New("source is not a Git source")
ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
)
// GitSourceUpdatePayload holds the parameters for creating a git-backed source
@@ -46,7 +46,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error {
// @id GitOpsSourcesUpdateGit
// @summary Update a Git source
// @description Updates an existing GitOps source backed by a Git repository.
// @description **Access policy**: authenticated
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -73,11 +73,6 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return httperror.BadRequest("Invalid request payload", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
sourceID := portainer.SourceID(id)
var src *portainer.Source
@@ -85,8 +80,7 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if src, err = tx.Source().Read(userContext, sourceID); err != nil {
if src, err = tx.Source().Read(sourceID); err != nil {
return err
}
@@ -94,14 +88,24 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return err
}
return tx.Source().Update(userContext, src.ID, src)
username, password := "", ""
if src.Git != nil && src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
if isUnique, err := workflows.ValidateUniqueSource(tx, src.Git.URL, username, password, sourceID); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Update(src.ID, src)
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrNotGitSource) {
return httperror.BadRequest("Source is not a Git source", err)
} else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to update source", err)
} else if errors.Is(err, source.ErrDuplicateSource) {
} else if errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil {
return httperror.InternalServerError("Unable to update source", err)

View File

@@ -20,8 +20,8 @@ func TestGitSourceUpdate_Success(t *testing.T) {
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -46,7 +46,7 @@ func TestGitSourceUpdate_Success(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "new-name", src.Name)
require.NotNil(t, src.Git)
require.Equal(t, "https://github.com/org/new", src.Git.URL)
require.Equal(t, "https://github.com/org/new.git", src.Git.URL)
}
func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
@@ -66,7 +66,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
},
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -89,7 +89,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
var stored *portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
stored, err = tx.Source().Read(adminUserContext, srcID)
stored, err = tx.Source().Read(srcID)
return err
}))
require.NotNil(t, stored.Git)
@@ -115,7 +115,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) {
},
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -138,7 +138,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) {
var stored *portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
stored, err = tx.Source().Read(adminUserContext, srcID)
stored, err = tx.Source().Read(srcID)
return err
}))
require.NotNil(t, stored.Git)
@@ -162,7 +162,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) {
},
},
}
err := tx.Source().Create(adminUserContext, src)
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -188,7 +188,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) {
var stored *portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
stored, err = tx.Source().Read(adminUserContext, srcID)
stored, err = tx.Source().Read(srcID)
return err
}))
require.NotNil(t, stored.Git)
@@ -229,11 +229,11 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) {
URL: "https://github.com/org/existing.git",
},
}
err := tx.Source().Create(adminUserContext, existing)
err := tx.Source().Create(existing)
require.NoError(t, err)
src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err = tx.Source().Create(adminUserContext, src)
src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit}
err = tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -253,14 +253,39 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) {
require.Equal(t, http.StatusConflict, rr.Code)
}
func TestGitSourceUpdate_NotGitSource(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "helm-source", Type: portainer.SourceTypeHelm}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body))
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestGitSourceUpdate_MalformedJSON(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
@@ -292,7 +317,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T)
},
},
}
if err := tx.Source().Create(adminUserContext, existing); err != nil {
if err := tx.Source().Create(existing); err != nil {
return err
}
@@ -301,7 +326,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T)
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"},
}
if err := tx.Source().Create(adminUserContext, other); err != nil {
if err := tx.Source().Create(other); err != nil {
return err
}
srcID = other.ID

View File

@@ -69,10 +69,12 @@ func TestBuildConnectionInfo(t *testing.T) {
assert.Equal(t, connectionInfo{}, buildConnectionInfo(nil))
cfg := &gittypes.RepoConfig{
ConfigFilePath: "docker-compose.yml",
TLSSkipVerify: true,
Authentication: &gittypes.GitAuthentication{Username: "user"},
}
got := buildConnectionInfo(cfg)
assert.Equal(t, "docker-compose.yml", got.ConfigFilePath)
assert.True(t, got.TLSSkipVerify)
require.NotNil(t, got.Authentication)
assert.Equal(t, "user", got.Authentication.Username)

View File

@@ -7,7 +7,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
@@ -25,7 +24,6 @@ func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.Use
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: security.IsAdminRole(role),
User: &portainer.User{ID: userID, Role: role},
})
return req.WithContext(ctx)
}
@@ -50,7 +48,7 @@ func createGitStack(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.
if stack.GitConfig != nil {
src := &portainer.Source{Git: stack.GitConfig, Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
require.NoError(t, tx.Source().Create(src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID,

View File

@@ -81,7 +81,7 @@ func TestWorkflowsList_Pagination(t *testing.T) {
createGitStack(t, tx, &portainer.Stack{
ID: portainer.StackID(i),
Name: fmt.Sprintf("stack-%d", i),
GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/y-%d", i)),
GitConfig: gitConfig("https://github.com/x/y"),
})
}

View File

@@ -23,7 +23,6 @@ func buildSummaryReq(t *testing.T, userID portainer.UserID, role portainer.UserR
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: security.IsAdminRole(role),
User: &portainer.User{ID: userID, Role: role},
})
return req.WithContext(ctx)
}

View File

@@ -6,7 +6,6 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
@@ -53,7 +52,7 @@ func createStackPayloadFromComposeFileContentPayload(name string, fileContent st
}
}
func (handler *Handler) checkAndCleanStackDupFromSwarm(_ http.ResponseWriter, _ *http.Request, _ *portainer.Endpoint, _ portainer.UserID, stack *portainer.Stack) error {
func (handler *Handler) checkAndCleanStackDupFromSwarm(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID, stack *portainer.Stack) error {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return err
@@ -280,18 +279,17 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
stackPayload := createStackPayloadFromComposeGitPayload(payload.Name,
strings.TrimSuffix(payload.RepositoryURL, "/"),
payload.RepositoryReferenceName,

View File

@@ -5,10 +5,8 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -236,13 +234,8 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}

View File

@@ -5,7 +5,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
@@ -219,17 +218,17 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name,
payload.SwarmID,
payload.RepositoryURL,

View File

@@ -15,8 +15,8 @@ type stackResponse struct {
// loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash)
// and the SourceID for the given stack.
func loadGitConfigForStack(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) {
src, file, err := workflows.GitSourceAndArtifactForStack(tx, userContext, workflowID, stackID)
func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) {
src, file, err := workflows.GitSourceAndArtifactForStack(tx, workflowID, stackID)
if err != nil || src == nil {
return nil, 0, err
}
@@ -27,7 +27,7 @@ func loadGitConfigForStack(tx dataservices.DataStoreTx, userContext *dataservice
// saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's
// artifact is repointed to that existing Source (selected by the caller) without modifying any
// Source's git config; otherwise the target Source is derived from cfg.URL.
func saveStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
matchArtifact := func(a portainer.Artifact) bool {
return a.StackID == stackID
}
@@ -41,16 +41,16 @@ func saveStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.S
})
}
return workflows.SaveWorkflowGitConfig(tx, userContext, workflowID, matchArtifact, oldSourceID, cfg)
return workflows.SaveWorkflowGitConfig(tx, workflowID, matchArtifact, oldSourceID, cfg)
}
// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId.
func newStackResponse(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) (*stackResponse, error) {
func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*stackResponse, error) {
if stack.WorkflowID == 0 {
return &stackResponse{Stack: *stack}, nil
}
gitConfig, gitSourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
gitConfig, gitSourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID)
if err != nil {
return nil, err
}
@@ -61,12 +61,12 @@ func newStackResponse(tx dataservices.DataStoreTx, userContext *dataservices.Sou
}
// fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses.
func fillStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) error {
func fillStackGitConfig(tx dataservices.DataStoreTx, stack *portainer.Stack) error {
if stack.WorkflowID == 0 {
return nil
}
gitConfig, _, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
gitConfig, _, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID)
if err != nil {
return err
}

View File

@@ -7,8 +7,6 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -122,13 +120,9 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
stack.ResourceControl = resourceControl
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return response.TxResponse(w, stack, err)
return response.JSON(w, stack)
}

View File

@@ -4,8 +4,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/stacks/stackutils"
@@ -128,29 +126,16 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
resourceControl = authorization.NewPrivateResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, userID)
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
err = tx.ResourceControl().Create(resourceControl)
if err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err)
}
stack.ResourceControl = resourceControl
err = handler.DataStore.ResourceControl().Create(resourceControl)
if err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err)
}
user, err := tx.User().Read(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user", err)
}
stack.ResourceControl = resourceControl
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user's team memberships", err)
}
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
userContext := source.NewUserContext(user, userMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
return response.TxResponse(w, stack, err)
return response.JSON(w, stack)
}

View File

@@ -192,7 +192,7 @@ func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, userID, stack, endpoint)
return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, stack, endpoint)
}
return handler.SwarmStackManager.Remove(ctx, stack, endpoint)
@@ -202,7 +202,7 @@ func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(ctx, userID, stack, endpoint)
return handler.StackDeployer.UndeployRemoteComposeStack(ctx, stack, endpoint)
}
return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint)

View File

@@ -4,8 +4,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
@@ -98,16 +96,10 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
var gitConfig *gittypes.RepoConfig
if stack.WorkflowID != 0 {
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, _, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
}); err != nil {
return response.TxErrorResponse(err)
var err error
gitConfig, _, err = loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
}

View File

@@ -8,7 +8,6 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
@@ -46,7 +45,7 @@ func TestStackFile_GitPendingRedeploy_Returns409(t *testing.T) {
ConfigFilePath: "docker-compose.yml",
},
}
require.NoError(t, store.Source().Create(source.InsecureNewAdminContext(), src))
require.NoError(t, store.Source().Create(src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stackID,

View File

@@ -4,7 +4,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
@@ -92,8 +91,7 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
}
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
resp, err := newStackResponse(handler.DataStore, userContext, stack)
resp, err := newStackResponse(handler.DataStore, stack)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}

View File

@@ -4,8 +4,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
@@ -81,17 +79,13 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs)
}
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
for i := range stacks {
if err := fillStackGitConfig(tx, userContext, &stacks[i]); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
for i := range stacks {
if err := fillStackGitConfig(handler.DataStore, &stacks[i]); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
}
return response.TxResponse(w, stacks, err)
return response.JSON(w, stacks)
}
// filterStacks refines a collection of Stack instances using specified criteria.

View File

@@ -7,8 +7,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -174,17 +172,11 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
}
}
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
return response.TxResponse(w, stack, err)
return response.JSON(w, stack)
}
func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError {

View File

@@ -9,7 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -137,7 +136,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID
}
if err := handler.startStack(context.TODO(), securityContext.UserID, stack, endpoint, securityContext); err != nil {
if err := handler.startStack(context.TODO(), stack, endpoint, securityContext); err != nil {
stack.Status = portainer.StackStatusError
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
Status: portainer.StackStatusError,
@@ -157,25 +156,21 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.DeploymentStatus = []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusActive, Time: time.Now().Unix()},
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to update stack status", err)
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
return httperror.InternalServerError("Unable to update stack status", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
return response.TxResponse(w, stack, err)
return response.JSON(w, stack)
}
func (handler *Handler) startStack(
ctx context.Context,
userID portainer.UserID,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
securityContext *security.RestrictedRequestContext,
@@ -197,7 +192,7 @@ func (handler *Handler) startStack(
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(ctx, userID, stack, endpoint, filteredRegistries)
return handler.StackDeployer.StartRemoteComposeStack(ctx, stack, endpoint, filteredRegistries)
}
return handler.StackDeployer.DeployComposeStack(ctx, stack, endpoint, filteredRegistries, false, false, false)
@@ -205,7 +200,7 @@ func (handler *Handler) startStack(
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(ctx, userID, stack, endpoint, filteredRegistries)
return handler.StackDeployer.StartRemoteSwarmStack(ctx, stack, endpoint, filteredRegistries)
}
return handler.StackDeployer.DeploySwarmStack(ctx, stack, endpoint, filteredRegistries, true, true)

View File

@@ -7,7 +7,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -109,7 +108,7 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
stack.AutoUpdate.JobID = ""
}
stopErr := handler.stopStack(r.Context(), securityContext.UserID, stack, endpoint)
stopErr := handler.stopStack(r.Context(), stack, endpoint)
if stopErr != nil {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stackutils.UpdateStackStatusFromUndeploymentResult(stack, stopErr)
@@ -121,29 +120,27 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
return httperror.InternalServerError("Unable to stop stack", stopErr)
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stackutils.UpdateStackStatusFromUndeploymentResult(stack, nil)
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to update stack status", err)
}
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
return httperror.InternalServerError("Unable to update stack status", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return response.TxResponse(w, stack, err)
return response.JSON(w, stack)
}
func (handler *Handler) stopStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
switch stack.Type {
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(ctx, userId, stack, endpoint)
return handler.StackDeployer.StopRemoteComposeStack(ctx, stack, endpoint)
}
return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint)
@@ -151,7 +148,7 @@ func (handler *Handler) stopStack(ctx context.Context, userId portainer.UserID,
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(ctx, userId, stack, endpoint)
return handler.StackDeployer.StopRemoteSwarmStack(ctx, stack, endpoint)
}
return handler.SwarmStackManager.Remove(ctx, stack, endpoint)

View File

@@ -47,7 +47,6 @@ func mockCreateStackRequestWithSecurityContext(method, target string, body io.Re
ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
IsAdmin: true,
UserID: portainer.UserID(1),
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
})
return req.WithContext(ctx)

View File

@@ -8,7 +8,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -189,8 +188,7 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
deployGate.startDeploy()
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
if err := fillStackGitConfig(tx, stack); err != nil {
return nil, httperror.InternalServerError("Unable to load git config for stack", err)
}

View File

@@ -8,7 +8,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources"
@@ -88,28 +87,13 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError(msg, errors.New(msg))
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return httperror.InternalServerError("Unable to load git config for stack", err)
}
var gitConfig *gittypes.RepoConfig
var sourceID portainer.SourceID
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
if gitConfig == nil {
msg := "No Git config in the found stack source"
return httperror.InternalServerError(msg, errors.New(msg))
}
return nil
}); err != nil {
return response.TxErrorResponse(err)
if gitConfig == nil {
msg := "No Git config in the found stack source"
return httperror.InternalServerError(msg, errors.New(msg))
}
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
@@ -142,6 +126,11 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.Forbidden("Permission denied to access environment", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
@@ -204,10 +193,8 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
stack.Option = &portainer.StackOption{Prune: payload.Prune}
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID)
src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID)
if httpErr != nil {
return httpErr
}
@@ -263,12 +250,11 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil {
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil {
return err
}
var err error
resp, err = newStackResponse(tx, userContext, stack)
resp, err = newStackResponse(tx, stack)
return err
}); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)

View File

@@ -8,9 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
@@ -70,41 +68,27 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid stack identifier route variable", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
var stack *portainer.Stack
var gitConfig *gittypes.RepoConfig
var sourceID portainer.SourceID
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
stack, err = tx.Stack().Read(portainer.StackID(stackID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
if stack.WorkflowID == 0 {
return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow"))
}
if stack.WorkflowID == 0 {
return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow"))
}
gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
if gitConfig == nil {
return httperror.BadRequest("Stack is not created from git", errors.New("stack source has no git config"))
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
if gitConfig == nil {
return httperror.BadRequest("Stack is not created from git", errors.New("stack source has no git config"))
}
if stack.Status == portainer.StackStatusDeploying {
return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
}
return nil
}); err != nil {
return response.TxErrorResponse(err)
if stack.Status == portainer.StackStatusDeploying {
return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
@@ -129,6 +113,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httperror.Forbidden("Permission denied to access environment", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
// Only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
@@ -265,12 +254,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
return err
}
return fillStackGitConfig(tx, userContext, stack)
return fillStackGitConfig(tx, stack)
}); err != nil {
deployGate.abortDeploy()

View File

@@ -8,7 +8,6 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
@@ -37,23 +36,30 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
const stack1ID = portainer.StackID(456)
const stack2ID = portainer.StackID(457)
sharedSrc := &portainer.Source{
src1 := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"},
}
err = store.Source().Create(source.InsecureNewAdminContext(), sharedSrc)
err = store.Source().Create(src1)
require.NoError(t, err)
wf1 := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack1ID,
Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}},
Files: []portainer.ArtifactFile{{SourceID: src1.ID}},
}}}
err = store.Workflow().Create(wf1)
require.NoError(t, err)
src2 := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"},
}
err = store.Source().Create(src2)
require.NoError(t, err)
wf2 := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack2ID,
Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}},
Files: []portainer.ArtifactFile{{SourceID: src2.ID}},
}}}
err = store.Workflow().Create(wf2)
require.NoError(t, err)
@@ -93,11 +99,7 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID))
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload))
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
rrc := &security.RestrictedRequestContext{}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
rr := httptest.NewRecorder()

View File

@@ -8,7 +8,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
@@ -55,15 +54,8 @@ func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error
}
func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if stack.WorkflowID != 0 {
gitConfig, sourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
gitConfig, sourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
@@ -119,7 +111,7 @@ func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *ht
stack.AutoUpdate.JobID = jobID
}
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
return httperror.InternalServerError("Unable to update source git config", err)
}

View File

@@ -52,14 +52,15 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.WrapTransportInternal(&http.Transport{TLSClientConfig: tlsConfig})
innerTransport = ssrf.NewInternalTransport(tlsConfig)
} else {
innerTransport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
innerTransport = ssrf.NewTransport(tlsConfig)
innerTransport.Protocols = ssrf.HTTP1Only()
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.WrapTransportInternal(&http.Transport{})
innerTransport = ssrf.NewInternalTransport(nil)
} else {
innerTransport = ssrf.WrapTransport(&http.Transport{})
innerTransport = ssrf.NewTransport(nil)
}
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)

View File

@@ -68,14 +68,14 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.WrapTransportInternal(&http.Transport{TLSClientConfig: tlsConfig})
innerTransport = ssrf.NewInternalTransport(tlsConfig)
} else {
innerTransport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
innerTransport = ssrf.NewTransport(tlsConfig)
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.WrapTransportInternal(&http.Transport{})
innerTransport = ssrf.NewInternalTransport(nil)
} else {
innerTransport = ssrf.WrapTransport(&http.Transport{})
innerTransport = ssrf.NewTransport(nil)
}
dockerTransport, err := docker.NewTransport(transportParameters, innerTransport, factory.gitService, factory.snapshotService)

View File

@@ -3,11 +3,13 @@
package factory
import (
"context"
"net"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
@@ -31,9 +33,11 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
}
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
d := &net.Dialer{}
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return d.DialContext(ctx, "unix", socketPath)
}
return t
}

View File

@@ -3,12 +3,14 @@
package factory
import (
"context"
"net"
"net/http"
"github.com/Microsoft/go-winio"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/docker"
"github.com/portainer/portainer/pkg/libhttp/ssrf"
)
func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portainer.Endpoint) (http.Handler, error) {
@@ -32,9 +34,10 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
}
func newNamedPipeTransport(namedPipePath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return winio.DialPipe(namedPipePath, nil)
},
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipe(namedPipePath, nil)
}
return t
}

View File

@@ -94,7 +94,7 @@ func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(ssrf.WrapTransport(&http.Transport{})), // Use ORAS retry transport for consistent rate limiting and error handling
transport: retry.NewTransport(ssrf.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}

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