Compare commits

..

13 Commits

Author SHA1 Message Date
Phil Calder
3279c31b21 docs(security): add FAQ link to setup token messages [BE-13125] (#3004)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2026-06-23 18:26:33 +12:00
LP B
272d3a47ae feat(app/sources): UAC on sources (#2997)
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
2026-06-22 20:38:21 -03:00
andres-portainer
f4ac9bae2e fix(helm): add missing SSRF protections BE-13136 (#3000) 2026-06-22 20:25:00 -03:00
Oscar Zhou
8e9b8385c8 fix(ui): update server installation timeout redirect link [BE-13124] (#2992) 2026-06-23 08:50:17 +12:00
Chaim Lev-Ari
9bc903b3cb feat(gitops): add "create new source" button to GitSourceSelector [BE-13054] (#2981)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 17:19:49 +03:00
Chaim Lev-Ari
1b88758768 refactor(gitops): remove manual credential entry from git form [BE-13047] (#2963)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 16:21:01 +03:00
Phil Calder
a9d6031b85 fix(websocket): enforce environment authorization on kubernetes-shell [BE-13027] (release/2.43 backport) (#2987)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:33:06 +12:00
andres-portainer
5a2e53e853 feat(ssrf): add missing transport wrappings and more checks BE-13021 (#2967) 2026-06-19 16:43:15 -03:00
andres-portainer
eee086e378 fix(git): avoid cloning to memory and bypassing symlinking restriction BE-13115 (#2962) 2026-06-18 16:04:56 -03:00
andres-portainer
0ba6bc6a01 fix(gitops/sources): fix the handling of the newer structure BE-12919 (#2931) 2026-06-17 09:08:30 -03:00
Chaim Lev-Ari
98bd985142 feat(custom-templates): reuse existing git sources in create/update [BE-13053] (#2929) 2026-06-16 14:24:38 -03:00
Chaim Lev-Ari
097d669670 feat(edge/stacks): use source ID for edge stack git creation [BE-13044] (#2928)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:33:49 +03:00
Chaim Lev-Ari
61bbfa15a1 feat(stacks): use source for kubernetes manifest git stacks [BE-13045] (#2927)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 15:23:47 +03:00
194 changed files with 3291 additions and 2184 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

View File

@@ -1,86 +0,0 @@
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,9 +108,6 @@ linters:
linters:
- gocritic
text: ruleguard
- path: pkg/libhttp/ssrf/builder\.go
linters:
- forbidigo
paths:
- third_party$
- builtin$

View File

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

View File

@@ -6,7 +6,6 @@ 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
@@ -91,25 +90,13 @@ format-server: ## Format server code
go fmt ./...
##@ Lint
.PHONY: lint lint-client lint-server check-lint-version
.PHONY: lint lint-client lint-server
lint: lint-client lint-server ## Lint all code
lint-client: ## Lint client code
pnpm run lint
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
lint-server: tidy ## 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,72 +4,38 @@ package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// 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.
// unwrappedHTTPTransport flags http.Transport composite literals that are not
// the direct argument to ssrf.WrapTransport.
func unwrappedHTTPTransport(m dsl.Matcher) {
// Inline construction passed to a function call.
m.Match(`$f(&http.Transport{$*_})`).
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
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`)
// Variable assigned a bare transport (cannot be tracked to a later WrapTransport call).
m.Match(`$_ := &http.Transport{$*_}`).
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
Report(`bare *http.Transport variable; use ssrf.WrapTransport(&http.Transport{...}) inline instead`)
// Field assignment of a bare transport (e.g. httpClient.Transport = &http.Transport{...}).
m.Match(`$_.Transport = &http.Transport{$*_}`).
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
Report(`bare *http.Transport field assignment; wrap with ssrf.WrapTransport() to enforce the SSRF protection policy`)
}
// 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 created via ssrf.NewTransport.
// passed here must be wrapped with ssrf.WrapTransport.
func helmGetterTransport(m dsl.Matcher) {
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
Report(`getter.WithTransport called with a bare *http.Transport; wrap with ssrf.WrapTransport() as Helm v4 bypasses http.DefaultTransport`)
}
// 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
// internalTransportMisuse flags calls to WrapTransportInternal outside the four proxy
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
func internalTransportMisuse(m dsl.Matcher) {
m.Match(`ssrf.NewInternalTransport($*_)`).
m.Match(`ssrf.WrapTransportInternal($*_)`).
Where(
!(m.File().PkgPath.Matches(`proxy/factory`) &&
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`)
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`)
}

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.NewTransport(tlsConfig)
httpCli.Transport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: 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 !ssrf.WrapDefaultTransport() {
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
if dt, ok := nethttp.DefaultTransport.(*nethttp.Transport); ok {
nethttp.DefaultTransport = ssrf.WrapTransport(dt)
}
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})

View File

@@ -195,9 +195,20 @@ type (
BucketName() string
}
SourceServiceUserContext struct {
User *portainer.User
UserMemberships []portainer.TeamMembership
}
// SourceService represents a service for managing GitOps source data
SourceService interface {
BaseCRUD[portainer.Source, portainer.SourceID]
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)
}
// StackService represents a service for managing stack data

View File

@@ -0,0 +1,133 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,74 @@
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

@@ -0,0 +1,72 @@
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 {
dataservices.BaseDataService[portainer.Source, portainer.SourceID]
base 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{
BaseDataService: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
base: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: connection,
},
@@ -30,21 +30,77 @@ func NewService(connection portainer.Connection) (*Service, error) {
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
base: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: service.Connection,
Connection: service.base.Connection,
Tx: tx,
},
}
}
// Create creates a new source.
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
},
)
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
}

View File

@@ -3,15 +3,36 @@ 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 {
dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
base dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
}
// Create creates a new source.
func (service ServiceTx) Create(source *portainer.Source) error {
return service.Tx.CreateObject(
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(
BucketName,
func(id uint64) (int, any) {
source.ID = portainer.SourceID(id)
@@ -19,3 +40,165 @@ func (service ServiceTx) Create(source *portainer.Source) error {
},
)
}
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,97 @@
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,6 +4,7 @@ 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"
@@ -52,9 +53,11 @@ func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig {
}
type legacyStack struct {
ID int `json:"Id"`
GitConfig *legacyRepoConfig `json:"GitConfig"`
WorkflowID *int
ID int `json:"Id"`
GitConfig *legacyRepoConfig `json:"GitConfig"`
WorkflowID *int
ResourceControl *portainer.ResourceControl `json:"ResourceControl"`
CreatedBy string
}
// sourceDedupeKey is the identity used to detect duplicate Sources during migration.
@@ -98,7 +101,8 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
return err
}
existingSources, err := m.sourceService.ReadAll()
adminUserContext := source.InsecureNewAdminContext()
existingSources, err := m.sourceService.ReadAll(adminUserContext)
if err != nil {
return err
}
@@ -122,19 +126,53 @@ 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: cfg,
Git: &gittypes.RepoConfig{
URL: cfg.URL,
Authentication: cfg.Authentication,
TLSSkipVerify: cfg.TLSSkipVerify,
},
OwnerID: ownerId,
Public: public,
AdministratorsOnly: adminOnly,
UserAccesses: users,
TeamAccesses: teams,
}
if err := m.sourceService.Tx(tx).Create(src); err != nil {
if err := m.sourceService.Tx(tx).Create(adminUserContext, 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))
@@ -182,7 +220,8 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
return err
}
existingSources, err := m.sourceService.ReadAll()
adminUserContext := source.InsecureNewAdminContext()
existingSources, err := m.sourceService.ReadAll(adminUserContext)
if err != nil {
return err
}
@@ -211,19 +250,48 @@ 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,
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
OwnerID: ownerId,
Public: public,
AdministratorsOnly: adminOnly,
UserAccesses: users,
TeamAccesses: teams,
}
if err := m.sourceService.Tx(tx).Create(src); err != nil {
if err := m.sourceService.Tx(tx).Create(adminUserContext, 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,6 +15,10 @@ 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()
@@ -61,11 +65,12 @@ 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(wf.Artifacts[0].Files[0].SourceID)
src, err := sourceSvc.Read(adminUserContext, 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.Equal(t, gitStack.GitConfig.ReferenceName, src.Git.ReferenceName)
require.Empty(t, src.Git.ReferenceName)
require.Equal(t, gitStack.GitConfig.ReferenceName, wf.Artifacts[0].Files[0].Ref)
}
func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
@@ -104,7 +109,7 @@ func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
require.Zero(t, result.WorkflowID)
require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Empty(t, sources)
@@ -160,7 +165,7 @@ func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T)
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 1, "two stacks with the same URL must share one Source")
@@ -214,7 +219,7 @@ func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 1)
@@ -268,7 +273,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(migrated.Artifact.Files[0].SourceID)
src, err := sourceSvc.Read(adminUserContext, 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)
@@ -307,7 +312,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(
require.Nil(t, result.Artifact)
require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Empty(t, sources)
}
@@ -350,7 +355,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Empty(t, sources, "no new sources should be created for already-migrated templates")
}
@@ -402,7 +407,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source")
@@ -456,7 +461,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err)
sources, err := sourceSvc.ReadAll()
sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 1)
}

View File

@@ -0,0 +1,122 @@
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(); err != nil {
if s, err := store.Source().ReadAll(source.InsecureNewAdminContext()); 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(v.ID, &v); err != nil {
if err := store.Source().Update(source.InsecureNewAdminContext(), 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.NewTransport(tlsConfig),
Transport: ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig}),
}
} else {
transport = &NodeNameTransport{
Transport: ssrf.NewTransport(nil),
Transport: ssrf.WrapTransport(&http.Transport{}),
}
}

View File

@@ -66,8 +66,11 @@ func NewAzureClient() *azureClient {
func newHttpClientForAzure(insecureSkipVerify bool) *http.Client {
return &http.Client{
Transport: ssrf.NewTransport(crypto.CreateTLSConfiguration(insecureSkipVerify)),
Timeout: 300 * time.Second,
Transport: ssrf.WrapTransport(&http.Transport{
TLSClientConfig: crypto.CreateTLSConfiguration(insecureSkipVerify),
Proxy: http.ProxyFromEnvironment,
}),
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.ListContext(ctx, opt)
refs, err := rem.List(opt)
if err != nil {
return nil, checkGitError(err)
}

View File

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

View File

@@ -2,6 +2,7 @@ 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"
@@ -23,14 +24,14 @@ type RepoConfigInput struct {
}
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
func ResolveRepoConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
cfg := gittypes.RepoConfig{
ReferenceName: input.ReferenceName,
ConfigFilePath: input.ConfigFilePath,
}
if input.SourceID != 0 {
src, httpErr := ValidateGitSourceAccess(tx, input.SourceID)
src, httpErr := ValidateGitSourceAccess(tx, userContext, 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(src))
require.NoError(t, store.Source().Create(adminUserContext, src))
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{
cfg, httpErr := ResolveRepoConfig(store, adminUserContext, 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, RepoConfigInput{
cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://github.com/org/repo",

View File

@@ -16,9 +16,8 @@ type gitSourceStore interface {
}
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
// 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)
func ValidateGitSourceAccess(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
src, err := tx.Source().Read(userContext, 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(src))
require.NoError(t, store.Source().Create(adminUserContext, src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
_, httpErr := ValidateGitSourceAccess(store, adminUserContext, src.ID)
assert.Nil(t, httpErr)
}
@@ -29,21 +29,7 @@ func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999))
_, httpErr := ValidateGitSourceAccess(store, adminUserContext, 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,6 +5,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"
"github.com/portainer/portainer/api/kubernetes/cli"
@@ -22,6 +23,8 @@ 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))
})
@@ -50,7 +53,7 @@ func FetchWorkflows(
workflowIDSet.Add(stack.WorkflowID)
}
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, workflowIDSet)
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet)
if err != nil {
return nil, err
}
@@ -113,7 +116,9 @@ func FetchSourceStats(
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) {
sources, err := tx.Source().ReadAll()
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
sources, err := tx.Source().ReadAll(userContext)
if err != nil {
return nil, nil, err
}

View File

@@ -15,7 +15,11 @@ import (
)
func adminContext() *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{IsAdmin: true, UserID: 1}
return &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
}
func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
@@ -24,7 +28,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(src))
require.NoError(t, tx.Source().Create(adminUserContext, src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID,
@@ -199,8 +203,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(&portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit}))
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit}))
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"}}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
@@ -223,8 +227,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}
require.NoError(t, tx.Source().Create(src))
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))
srcID = src.ID
for i := 1; i <= 2; i++ {
@@ -261,8 +265,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}
require.NoError(t, tx.Source().Create(src))
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))
unusedID = src.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})

View File

@@ -0,0 +1,7 @@
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, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) {
func GitSourceAndArtifactForStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, 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, workflowID portainer.Workfl
return nil, nil, err
}
sourceMap, err := loadWorkflowSources(tx, wf)
sourceMap, err := loadWorkflowSources(tx, userContext, wf)
if err != nil {
return nil, nil, err
}
@@ -57,7 +57,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.Workfl
// 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, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) {
func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, 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, workflowID portainer.Wo
return nil, nil, err
}
sourceMap, err := loadWorkflowSources(tx, wf)
sourceMap, err := loadWorkflowSources(tx, userContext, wf)
if err != nil {
return nil, nil, err
}
@@ -169,45 +169,15 @@ 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, 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
func FindOrCreateGitSource(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, src *portainer.Source) (*portainer.Source, error) {
return tx.Source().FindOrCreateGitSource(userContext, src)
}
// 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, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
src, err := tx.Source().Read(oldSourceID)
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)
if err != nil {
return fmt.Errorf("failed to read source: %w", err)
}
@@ -219,7 +189,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m
newSourceID := oldSourceID
if cfg.URL != src.Git.URL {
newSrc, err := FindOrCreateGitSource(tx, &portainer.Source{
newSrc, err := FindOrCreateGitSource(tx, userContext, &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
@@ -233,7 +203,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m
src.Git.Authentication = cfg.Authentication
src.Git.TLSSkipVerify = cfg.TLSSkipVerify
if err := tx.Source().Update(src.ID, src); err != nil {
if err := tx.Source().Update(userContext, src.ID, src); err != nil {
return fmt.Errorf("failed to update source: %w", err)
}
}
@@ -297,7 +267,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, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) {
func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, 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 {
@@ -313,7 +283,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.Workflow
}
}
srcMap, err := LoadSourceMap(tx, sourceIDs)
srcMap, err := loadSourceMap(tx, userContext, sourceIDs)
if err != nil {
return nil, nil, err
}
@@ -323,7 +293,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.Workflow
// 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, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
func loadWorkflowSources(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, 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 {
@@ -331,67 +301,22 @@ func loadWorkflowSources(tx gitSourceStore, wf *portainer.Workflow) (map[portain
}
}
return LoadSourceMap(tx, ids)
return loadSourceMap(tx, userContext, ids)
}
// 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) {
// 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
}
result := make(map[portainer.SourceID]portainer.Source, len(ids))
for id := range ids {
src, err := tx.Source().Read(id)
if err != nil {
return nil, err
}
result[id] = *src
for _, src := range sources {
result[src.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, 0, 1)
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, 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(gitSrc)
err := tx.Source().Create(adminUserContext, 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, workflowID, 42)
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, 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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -167,43 +167,7 @@ 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, 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)
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 99)
return txErr
})
require.NoError(t, err)
@@ -219,7 +183,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, 0, 1)
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, 0, 1)
return txErr
})
require.NoError(t, err)
@@ -237,7 +201,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/edge-repo"},
}
err := tx.Source().Create(gitSrc)
err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -262,7 +226,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, workflowID, 5)
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 5)
return txErr
})
require.NoError(t, err)
@@ -280,7 +244,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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
sourceID = src.ID
@@ -318,7 +282,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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
sourceID = src.ID
@@ -356,7 +320,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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
sourceID = src.ID
@@ -393,7 +357,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, &portainer.Source{
src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Name: "my-repo",
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
@@ -413,7 +377,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.
_, store := datastore.MustNewTestStore(t, false, true)
makeSource := func(tx dataservices.DataStoreTx) (*portainer.Source, error) {
return FindOrCreateGitSource(tx, &portainer.Source{
return FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -446,7 +410,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.
require.NoError(t, err)
require.Equal(t, firstID, secondID)
sources, err := store.Source().ReadAll()
sources, err := store.Source().ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 1)
}
@@ -456,7 +420,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, true)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, txErr := FindOrCreateGitSource(tx, &portainer.Source{
_, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -468,7 +432,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, txErr := FindOrCreateGitSource(tx, &portainer.Source{
_, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -479,7 +443,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
})
require.NoError(t, err)
sources, err := store.Source().ReadAll()
sources, err := store.Source().ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 2)
}
@@ -503,7 +467,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
},
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
sourceID = src.ID
@@ -539,7 +503,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
}
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, newCfg)
})
@@ -552,7 +516,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(sourceID)
src, err := store.Source().Read(adminUserContext, sourceID)
require.NoError(t, err)
require.Equal(t, "new-user", src.Git.Authentication.Username)
require.Equal(t, "new-pass", src.Git.Authentication.Password)
@@ -571,7 +535,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
oldSourceID = src.ID
@@ -592,7 +556,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, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, oldSourceID, newCfg)
})
@@ -603,7 +567,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
newSourceID := wf.Artifacts[0].Files[0].SourceID
require.NotEqual(t, oldSourceID, newSourceID)
newSrc, err := store.Source().Read(newSourceID)
newSrc, err := store.Source().Read(adminUserContext, newSourceID)
require.NoError(t, err)
require.Equal(t, "https://github.com/example/new-repo", newSrc.Git.URL)
}
@@ -620,7 +584,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"},
}
err := tx.Source().Create(old)
err := tx.Source().Create(adminUserContext, old)
require.NoError(t, err)
oldSourceID = old.ID
@@ -628,7 +592,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
}
err = tx.Source().Create(existing)
err = tx.Source().Create(adminUserContext, existing)
require.NoError(t, err)
existingSourceID = existing.ID
@@ -649,7 +613,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, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, oldSourceID, newCfg)
})
@@ -659,46 +623,11 @@ 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()
sources, err := store.Source().ReadAll(adminUserContext)
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)
@@ -711,7 +640,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
sourceID = src.ID
@@ -736,7 +665,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
@@ -759,7 +688,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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -804,7 +733,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(old)
err := tx.Source().Create(adminUserContext, old)
require.NoError(t, err)
oldSourceID = old.ID
@@ -818,7 +747,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
},
},
}
err = tx.Source().Create(selected)
err = tx.Source().Create(adminUserContext, selected)
require.NoError(t, err)
newSourceID = selected.ID
@@ -861,7 +790,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(newSourceID)
selected, err := store.Source().Read(adminUserContext, 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)
@@ -876,7 +805,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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -919,7 +848,7 @@ func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) {
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
sourceID = src.ID
@@ -971,7 +900,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
}
err := tx.Source().Create(gitSrc)
err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -992,7 +921,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, workflowID, 20)
src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 20)
return txErr
})
require.NoError(t, err)
@@ -1012,7 +941,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"},
}
err := tx.Source().Create(gitSrc)
err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
@@ -1033,7 +962,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, workflowID, 20)
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 20)
return txErr
})
require.NoError(t, err)
@@ -1070,7 +999,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, &portainer.Source{
src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: "https://user:secret@github.com/example/repo",
@@ -1081,97 +1010,3 @@ 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.NewTransport(tlsConfig)
transport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
} else {
transport = ssrf.NewTransport(nil)
transport = ssrf.WrapTransport(&http.Transport{})
}
client := &http.Client{

View File

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

View File

@@ -30,7 +30,14 @@ 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})
return r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role}))
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)
}
func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) {
@@ -272,7 +279,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -461,7 +474,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -497,7 +516,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -530,7 +555,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -569,7 +600,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -608,7 +645,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -650,7 +693,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -689,7 +738,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -748,7 +803,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -804,7 +865,13 @@ 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"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}))
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},
}))
rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r)
@@ -866,7 +933,7 @@ func TestCustomTemplateCreate_FromRepository_Success(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, stored.Artifact)
src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID)
src, err := tx.Source().Read(adminUserContext, 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)
@@ -903,7 +970,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()
sources, err := tx.Source().ReadAll(adminUserContext)
require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source")
@@ -1052,7 +1119,7 @@ func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T)
URL: "https://github.com/example/repo",
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent))

View File

@@ -7,7 +7,10 @@ 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"
@@ -49,9 +52,23 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
file := customTemplate.Artifact.Files[0]
src, err := handler.DataStore.Source().Read(file.SourceID)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve git source for custom template", err)
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)
}
if src.Git == nil {

View File

@@ -174,13 +174,14 @@ func Test_customTemplateGitFetch(t *testing.T) {
require.NoError(t, err, "error to get working directory")
src := &portainer.Source{
ID: 1,
Type: portainer.SourceTypeGit,
ID: 1,
Type: portainer.SourceTypeGit,
Public: true,
Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo",
},
}
err = store.Source().Create(src)
err = store.Source().Create(adminUserContext, src)
require.NoError(t, err, "error creating source")
const configFilePath = "test-config-path.txt"
@@ -336,31 +337,3 @@ 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,6 +6,7 @@ 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"
@@ -70,7 +71,8 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
populateGitConfig(tx, customTemplate)
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
return nil
})

View File

@@ -174,7 +174,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
TLSSkipVerify: true,
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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})
ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})
r = r.WithContext(ctx)
rr := httptest.NewRecorder()
herr := handler.customTemplateInspect(rr, r)

View File

@@ -5,6 +5,8 @@ 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"
@@ -37,47 +39,54 @@ 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)
}
if !securityContext.IsAdmin {
user, err := handler.DataStore.User().Read(securityContext.UserID)
var customTemplates []portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplates, err = tx.CustomTemplate().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve user information from the database", err)
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
}
userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships)
resourceControls, err := tx.ResourceControl().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve resource controls from the database", err)
}
customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs)
}
customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls)
customTemplates = filterByType(customTemplates, templateTypes)
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)
}
if edge != nil {
customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge
})
}
userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships)
for i := range customTemplates {
populateGitConfig(handler.DataStore, &customTemplates[i])
}
customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs)
}
return response.JSON(w, customTemplates)
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)
}
func retrieveEdgeParam(r *http.Request) *bool {

View File

@@ -28,7 +28,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) {
TLSSkipVerify: true,
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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}))
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, r)
@@ -97,7 +97,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) {
},
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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}))
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, r)

View File

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

View File

@@ -27,6 +27,14 @@ 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))
}
@@ -476,7 +484,7 @@ func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) {
URL: "https://github.com/example/repo",
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
return nil
@@ -630,7 +638,7 @@ func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, stored.Artifact)
src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID)
src, err := tx.Source().Read(adminUserContext, 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

@@ -0,0 +1,5 @@
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, template *portainer.CustomTemplate) {
func populateGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, template *portainer.CustomTemplate) {
if template.Artifact == nil || len(template.Artifact.Files) == 0 {
return
}
file := template.Artifact.Files[0]
src, err := tx.Source().Read(file.SourceID)
src, err := tx.Source().Read(userContext, file.SourceID)
if err != nil || src.Git == nil {
return
}

View File

@@ -19,7 +19,8 @@ func TestPopulateGitConfig_NilArtifactIsNoOp(t *testing.T) {
template := &portainer.CustomTemplate{ID: 1}
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template)
populateGitConfig(tx, adminUserContext, template)
return nil
})
@@ -40,39 +41,7 @@ func TestPopulateGitConfig_EmptySourceIDsIsNoOp(t *testing.T) {
}
err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
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)
populateGitConfig(tx, adminUserContext, template)
return nil
})
@@ -94,7 +63,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) {
TLSSkipVerify: true,
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -115,7 +84,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) {
}
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template)
populateGitConfig(tx, adminUserContext, template)
return nil
})
@@ -145,7 +114,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) {
},
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -161,7 +130,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) {
}
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template)
populateGitConfig(tx, adminUserContext, template)
return nil
})

View File

@@ -1,95 +0,0 @@
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,9 +44,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Use(bouncer.AuthenticatedAccess)
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"), dockerOnlyMiddleware)
// /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)
endpointRouter.Handle("/dashboard", 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,12 +7,15 @@ 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"
@@ -124,7 +127,13 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
return stack, nil
}
repoConfig, httpErr := sources.ResolveRepoConfig(tx, sources.RepoConfigInput{
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{
SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository,

View File

@@ -135,10 +135,6 @@ 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,8 +7,10 @@ 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"
@@ -87,8 +89,14 @@ 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, payload.SourceID)
src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, userContext, payload.SourceID)
if httpErr != nil {
return httpErr
}

View File

@@ -7,8 +7,9 @@ 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/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"
@@ -21,8 +22,16 @@ 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"`
@@ -41,7 +50,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**: administrator
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -61,26 +70,20 @@ 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 {
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) {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
return tx.Source().Create(userContext, src)
}); errors.Is(err, source.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)
@@ -99,8 +102,7 @@ func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) {
return src, nil
}
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without
// authentication.
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS, accesses) without authentication.
func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
name := payload.Name
if strings.TrimSpace(name) == "" {
@@ -114,6 +116,10 @@ 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.git", src.Git.URL)
require.Equal(t, "https://github.com/org/repo", src.Git.URL)
}
func TestGitSourceCreate_SanitizesCredentials(t *testing.T) {

View File

@@ -8,6 +8,8 @@ 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"
@@ -18,7 +20,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**: admin
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -36,8 +38,15 @@ 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 {
if exists, err := tx.Source().Exists(portainer.SourceID(sourceID)); err != nil {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if exists, err := tx.Source().Exists(userContext, portainer.SourceID(sourceID)); err != nil {
return err
} else if !exists {
return dserrors.ErrObjectNotFound
@@ -71,11 +80,13 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro
return ErrSourceInUse
}
return tx.Source().Delete(portainer.SourceID(sourceID))
return tx.Source().Delete(userContext, 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,6 +8,7 @@ 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"
)
@@ -19,8 +20,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}
err := tx.Source().Create(src)
src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -57,8 +58,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}
err := tx.Source().Create(src)
src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -99,8 +100,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}
err := tx.Source().Create(src)
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)
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,7 +27,18 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
return nil, ce.SourceStats{}, nil
}
wfIDSet := set.ToSet(slicesx.Map(wfs, func(wf portainer.Workflow) portainer.WorkflowID { return wf.ID }))
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
}
}
}
}
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
_, ok := wfIDSet[s.WorkflowID]
@@ -41,16 +52,32 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
items := make([]ce.Workflow, 0, len(stacks))
stats := ce.SourceStats{EndpointIDs: set.Set[portainer.EndpointID]{}}
for _, stacks := range stacks {
items = append(items, ce.MapStackToWorkflow(stacks, src.Git, unknown, unknown))
for _, s := range stacks {
gitCfg := gitConfigForArtifact(src.Git, artifactByStack[s.ID])
items = append(items, ce.MapStackToWorkflow(s, gitCfg, unknown, unknown))
stats.WorkflowCount++
if stacks.EndpointID != 0 {
stats.EndpointIDs.Add(stacks.EndpointID)
if s.EndpointID != 0 {
stats.EndpointIDs.Add(s.EndpointID)
}
if lastSync := ce.StackLastSyncDate(stacks); lastSync > stats.LastSync {
if lastSync := ce.StackLastSyncDate(s); 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,12 +1,15 @@
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"
@@ -17,7 +20,6 @@ type gitAuthInfo struct {
}
type connectionInfo struct {
ConfigFilePath string `json:"configFilePath"`
TLSSkipVerify bool `json:"tlsSkipVerify"`
Authentication *gitAuthInfo `json:"authentication,omitempty"`
}
@@ -27,12 +29,19 @@ 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
@@ -56,6 +65,11 @@ 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
@@ -64,7 +78,8 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
source, err = tx.Source().Read(sourceID)
userContext := sourceDS.NewUserContext(securityContext.User, securityContext.UserMemberships)
source, err = tx.Source().Read(userContext, sourceID)
if err != nil {
return err
}
@@ -75,15 +90,19 @@ 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)
}
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs)
access := BuildSourceAccess(source)
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs, access)
return response.JSON(w, detail)
}
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail {
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow, access SourceAccess) SourceDetail {
var autoUpdate *AutoUpdateInfo
if len(sourceWfs) > 0 {
autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
@@ -94,6 +113,29 @@ 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,
}
}
@@ -102,7 +144,6 @@ func buildConnectionInfo(cfg *gittypes.RepoConfig) connectionInfo {
return connectionInfo{}
}
return connectionInfo{
ConfigFilePath: cfg.ConfigFilePath,
TLSSkipVerify: cfg.TLSSkipVerify,
Authentication: buildGitAuthInfo(cfg.Authentication),
}

View File

@@ -56,10 +56,11 @@ 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,13 +42,15 @@ 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("/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)
adminRouter.Handle("/{id}/access", httperror.LoggerHandler(h.gitSourceUpdateAccess)).Methods(http.MethodPut)
return h
}

View File

@@ -9,6 +9,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"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -17,6 +18,8 @@ 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 {
@@ -25,9 +28,13 @@ func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portain
src := &portainer.Source{
Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit,
Git: cfg,
Git: &gittypes.RepoConfig{
URL: cfg.URL,
Authentication: cfg.Authentication,
TLSSkipVerify: cfg.TLSSkipVerify,
},
}
require.NoError(t, tx.Source().Create(src))
require.NoError(t, tx.Source().Create(adminUserContext, src))
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
@@ -51,13 +58,19 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -65,9 +78,7 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -100,9 +111,7 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -111,9 +120,7 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -121,9 +128,7 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -131,9 +136,7 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -142,9 +145,7 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}
@@ -152,8 +153,6 @@ 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, &security.RestrictedRequestContext{
UserID: userID, IsAdmin: true,
}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
return req
}

View File

@@ -9,7 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows"
"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 := ceWorkflows.ParseStatus(status)
s, err := workflows.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]ceWorkflows.SourceStats
var stats map[portainer.SourceID]workflows.SourceStats
if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
allSrcs, stats, err = ceWorkflows.FetchSourceStats(tx, h.k8sFactory, sc)
allSrcs, stats, err = workflows.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 {
s, accessible := stats[src.ID]
if !accessible && !sc.IsAdmin {
continue
stat, ok := stats[src.ID]
if !ok {
stat = workflows.SourceStats{}
}
result = append(result, h.buildSource(ctx, &src, s))
result = append(result, h.buildSource(ctx, &src, stat))
}
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(src))
require.NoError(t, tx.Source().Create(adminUserContext, src))
wfA := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
require.NoError(t, tx.Workflow().Create(wfA))

View File

@@ -8,7 +8,9 @@ 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"
@@ -17,7 +19,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**: administrator
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -37,6 +39,11 @@ 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)
@@ -44,10 +51,13 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *
var src *portainer.Source
if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
src, err = tx.Source().Read(portainer.SourceID(sourceID))
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
src, err = tx.Source().Read(userContext, 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)
}
@@ -75,7 +85,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**: administrator
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt

View File

@@ -1,6 +1,7 @@
package sources
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -8,6 +9,7 @@ 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"
@@ -38,10 +40,9 @@ 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 _, name := range []string{"source-a", "source-b", "source-c"} {
err := tx.Source().Create(&portainer.Source{Name: name, Type: portainer.SourceTypeGit})
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)}})
require.NoError(t, err)
}

View File

@@ -0,0 +1,101 @@
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,8 +7,9 @@ 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/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"
@@ -16,8 +17,7 @@ import (
)
var (
ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
ErrNotGitSource = errors.New("source is not a Git source")
)
// 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**: administrator
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
@@ -73,6 +73,11 @@ 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
@@ -80,7 +85,8 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
if src, err = tx.Source().Read(sourceID); err != nil {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if src, err = tx.Source().Read(userContext, sourceID); err != nil {
return err
}
@@ -88,24 +94,14 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return err
}
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)
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 errors.Is(err, ErrNotGitSource) {
return httperror.BadRequest("Source is not a Git source", err)
} else if errors.Is(err, ErrDuplicateSource) {
} else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to update source", err)
} else if errors.Is(err, source.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}
err := tx.Source().Create(src)
src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, 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.git", src.Git.URL)
require.Equal(t, "https://github.com/org/new", src.Git.URL)
}
func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
@@ -66,7 +66,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
},
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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(srcID)
stored, err = tx.Source().Read(adminUserContext, srcID)
return err
}))
require.NotNil(t, stored.Git)
@@ -115,7 +115,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) {
},
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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(srcID)
stored, err = tx.Source().Read(adminUserContext, srcID)
return err
}))
require.NotNil(t, stored.Git)
@@ -162,7 +162,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) {
},
},
}
err := tx.Source().Create(src)
err := tx.Source().Create(adminUserContext, 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(srcID)
stored, err = tx.Source().Read(adminUserContext, 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(existing)
err := tx.Source().Create(adminUserContext, existing)
require.NoError(t, err)
src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit}
err = tx.Source().Create(src)
src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err = tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -253,39 +253,14 @@ 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}
err := tx.Source().Create(src)
src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err)
srcID = src.ID
@@ -317,7 +292,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T)
},
},
}
if err := tx.Source().Create(existing); err != nil {
if err := tx.Source().Create(adminUserContext, existing); err != nil {
return err
}
@@ -326,7 +301,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(other); err != nil {
if err := tx.Source().Create(adminUserContext, other); err != nil {
return err
}
srcID = other.ID

View File

@@ -69,12 +69,10 @@ 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,6 +7,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"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
@@ -24,6 +25,7 @@ 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)
}
@@ -48,7 +50,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(src))
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), 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("https://github.com/x/y"),
GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/y-%d", i)),
})
}

View File

@@ -23,6 +23,7 @@ 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,6 +6,7 @@ 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"
@@ -52,7 +53,7 @@ func createStackPayloadFromComposeFileContentPayload(name string, fileContent st
}
}
func (handler *Handler) checkAndCleanStackDupFromSwarm(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID, stack *portainer.Stack) error {
func (handler *Handler) checkAndCleanStackDupFromSwarm(_ http.ResponseWriter, _ *http.Request, _ *portainer.Endpoint, _ 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
@@ -279,15 +280,16 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
}
}
if payload.SourceID != 0 {
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)
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 {
return httpErr
}
}
stackPayload := createStackPayloadFromComposeGitPayload(payload.Name,

View File

@@ -5,8 +5,10 @@ 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"
@@ -234,8 +236,13 @@ 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, payload.SourceID); httpErr != nil {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
return httpErr
}
}

View File

@@ -5,6 +5,7 @@ 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"
@@ -218,15 +219,15 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
}
}
if payload.SourceID != 0 {
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)
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 {
return httpErr
}
}
stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name,

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, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) {
src, file, err := workflows.GitSourceAndArtifactForStack(tx, workflowID, stackID)
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)
if err != nil || src == nil {
return nil, 0, err
}
@@ -27,7 +27,7 @@ func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.Wor
// 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, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
func saveStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, 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, workflowID portainer.Workfl
})
}
return workflows.SaveWorkflowGitConfig(tx, workflowID, matchArtifact, oldSourceID, cfg)
return workflows.SaveWorkflowGitConfig(tx, userContext, workflowID, matchArtifact, oldSourceID, cfg)
}
// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId.
func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*stackResponse, error) {
func newStackResponse(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) (*stackResponse, error) {
if stack.WorkflowID == 0 {
return &stackResponse{Stack: *stack}, nil
}
gitConfig, gitSourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID)
gitConfig, gitSourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil {
return nil, err
}
@@ -61,12 +61,12 @@ func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*sta
}
// fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses.
func fillStackGitConfig(tx dataservices.DataStoreTx, stack *portainer.Stack) error {
func fillStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) error {
if stack.WorkflowID == 0 {
return nil
}
gitConfig, _, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID)
gitConfig, _, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil {
return err
}

View File

@@ -7,6 +7,8 @@ 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"
@@ -120,9 +122,13 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
stack.ResourceControl = resourceControl
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
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
})
return response.JSON(w, stack)
return response.TxResponse(w, stack, err)
}

View File

@@ -4,6 +4,8 @@ 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"
@@ -126,16 +128,29 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
resourceControl = authorization.NewPrivateResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, userID)
}
err = handler.DataStore.ResourceControl().Create(resourceControl)
if err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err)
}
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
stack.ResourceControl = resourceControl
user, err := tx.User().Read(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user", err)
}
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user's team memberships", err)
}
return response.JSON(w, stack)
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)
}

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, stack, endpoint)
return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, userID, 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, stack, endpoint)
return handler.StackDeployer.UndeployRemoteComposeStack(ctx, userID, stack, endpoint)
}
return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint)

View File

@@ -4,6 +4,8 @@ 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"
@@ -96,10 +98,16 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
var gitConfig *gittypes.RepoConfig
if stack.WorkflowID != 0 {
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)
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)
}
}

View File

@@ -8,6 +8,7 @@ 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"
@@ -45,7 +46,7 @@ func TestStackFile_GitPendingRedeploy_Returns409(t *testing.T) {
ConfigFilePath: "docker-compose.yml",
},
}
require.NoError(t, store.Source().Create(src))
require.NoError(t, store.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stackID,

View File

@@ -4,6 +4,7 @@ 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"
@@ -91,7 +92,8 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
}
}
resp, err := newStackResponse(handler.DataStore, stack)
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
resp, err := newStackResponse(handler.DataStore, userContext, stack)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}

View File

@@ -4,6 +4,8 @@ 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"
@@ -79,13 +81,17 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs)
}
for i := range stacks {
if err := fillStackGitConfig(handler.DataStore, &stacks[i]); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
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)
}
}
}
return nil
})
return response.JSON(w, stacks)
return response.TxResponse(w, stacks, err)
}
// filterStacks refines a collection of Stack instances using specified criteria.

View File

@@ -7,6 +7,8 @@ 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"
@@ -172,11 +174,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
}
}
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
return response.JSON(w, stack)
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)
}
func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError {

View File

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

View File

@@ -7,6 +7,7 @@ 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"
@@ -108,7 +109,7 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
stack.AutoUpdate.JobID = ""
}
stopErr := handler.stopStack(r.Context(), stack, endpoint)
stopErr := handler.stopStack(r.Context(), securityContext.UserID, stack, endpoint)
if stopErr != nil {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stackutils.UpdateStackStatusFromUndeploymentResult(stack, stopErr)
@@ -120,27 +121,29 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
return httperror.InternalServerError("Unable to stop stack", stopErr)
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stackutils.UpdateStackStatusFromUndeploymentResult(stack, nil)
return tx.Stack().Update(stack.ID, stack)
}); err != nil {
return httperror.InternalServerError("Unable to update stack status", err)
}
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to update stack status", err)
}
if err := fillStackGitConfig(handler.DataStore, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", 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
})
return response.JSON(w, stack)
return response.TxResponse(w, stack, err)
}
func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (handler *Handler) stopStack(ctx context.Context, userId portainer.UserID, 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, stack, endpoint)
return handler.StackDeployer.StopRemoteComposeStack(ctx, userId, stack, endpoint)
}
return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint)
@@ -148,7 +151,7 @@ func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, e
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(ctx, stack, endpoint)
return handler.StackDeployer.StopRemoteSwarmStack(ctx, userId, stack, endpoint)
}
return handler.SwarmStackManager.Remove(ctx, stack, endpoint)

View File

@@ -47,6 +47,7 @@ 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,6 +8,7 @@ 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"
@@ -188,7 +189,8 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
deployGate.startDeploy()
if err := fillStackGitConfig(tx, stack); err != nil {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return nil, httperror.InternalServerError("Unable to load git config for stack", err)
}

View File

@@ -8,6 +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/git/update"
"github.com/portainer/portainer/api/gitops/sources"
@@ -87,13 +88,28 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError(msg, errors.New(msg))
}
gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if gitConfig == nil {
msg := "No Git config in the found stack source"
return httperror.InternalServerError(msg, errors.New(msg))
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 payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
@@ -126,11 +142,6 @@ 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"))
@@ -193,8 +204,10 @@ 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, payload.SourceID)
src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID)
if httpErr != nil {
return httpErr
}
@@ -250,11 +263,12 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err
}
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil {
return err
}
var err error
resp, err = newStackResponse(tx, stack)
resp, err = newStackResponse(tx, userContext, stack)
return err
}); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)

View File

@@ -8,7 +8,9 @@ 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"
@@ -68,27 +70,41 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid stack identifier route variable", 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)
}
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)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
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"))
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if stack.Status == portainer.StackStatusDeploying {
return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
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"))
}
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)
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
@@ -113,11 +129,6 @@ 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)
@@ -254,11 +265,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err
}
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
return err
}
return fillStackGitConfig(tx, stack)
return fillStackGitConfig(tx, userContext, stack)
}); err != nil {
deployGate.abortDeploy()

View File

@@ -8,6 +8,7 @@ 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"
@@ -36,30 +37,23 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
const stack1ID = portainer.StackID(456)
const stack2ID = portainer.StackID(457)
src1 := &portainer.Source{
sharedSrc := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"},
}
err = store.Source().Create(src1)
err = store.Source().Create(source.InsecureNewAdminContext(), sharedSrc)
require.NoError(t, err)
wf1 := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack1ID,
Files: []portainer.ArtifactFile{{SourceID: src1.ID}},
Files: []portainer.ArtifactFile{{SourceID: sharedSrc.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: src2.ID}},
Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}},
}}}
err = store.Workflow().Create(wf2)
require.NoError(t, err)
@@ -99,7 +93,11 @@ 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{}
rrc := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
rr := httptest.NewRecorder()

View File

@@ -8,6 +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/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update"
@@ -54,8 +55,15 @@ 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, stack.WorkflowID, stack.ID)
gitConfig, sourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
@@ -111,7 +119,7 @@ func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *ht
stack.AutoUpdate.JobID = jobID
}
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
return httperror.InternalServerError("Unable to update source git config", err)
}

View File

@@ -52,15 +52,14 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
endpointURL.Scheme = "https"
if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(tlsConfig)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{TLSClientConfig: tlsConfig})
} else {
innerTransport = ssrf.NewTransport(tlsConfig)
innerTransport.Protocols = ssrf.HTTP1Only()
innerTransport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(nil)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{})
} else {
innerTransport = ssrf.NewTransport(nil)
innerTransport = ssrf.WrapTransport(&http.Transport{})
}
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.NewInternalTransport(tlsConfig)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{TLSClientConfig: tlsConfig})
} else {
innerTransport = ssrf.NewTransport(tlsConfig)
innerTransport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig})
}
} else if endpointutils.IsEdgeEndpoint(endpoint) {
innerTransport = ssrf.NewInternalTransport(nil)
innerTransport = ssrf.WrapTransportInternal(&http.Transport{})
} else {
innerTransport = ssrf.NewTransport(nil)
innerTransport = ssrf.WrapTransport(&http.Transport{})
}
dockerTransport, err := docker.NewTransport(transportParameters, innerTransport, factory.gitService, factory.snapshotService)

View File

@@ -3,13 +3,11 @@
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) {
@@ -33,11 +31,9 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
}
func newSocketTransport(socketPath string) *http.Transport {
d := &net.Dialer{}
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
return d.DialContext(ctx, "unix", socketPath)
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
return t
}

View File

@@ -3,14 +3,12 @@
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) {
@@ -34,10 +32,9 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
}
func newNamedPipeTransport(namedPipePath string) *http.Transport {
t := ssrf.NewInternalTransport(nil)
t.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipe(namedPipePath, nil)
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err 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.NewTransport(nil)), // Use ORAS retry transport for consistent rate limiting and error handling
transport: retry.NewTransport(ssrf.WrapTransport(&http.Transport{})), // 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