Compare commits
13 Commits
develop
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3279c31b21 | ||
|
|
272d3a47ae | ||
|
|
f4ac9bae2e | ||
|
|
8e9b8385c8 | ||
|
|
9bc903b3cb | ||
|
|
1b88758768 | ||
|
|
a9d6031b85 | ||
|
|
5a2e53e853 | ||
|
|
eee086e378 | ||
|
|
0ba6bc6a01 | ||
|
|
98bd985142 | ||
|
|
097d669670 | ||
|
|
61bbfa15a1 |
4
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
4
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
@@ -3,13 +3,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
Thanks for suggesting an idea for Portainer!
|
||||
|
||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||
|
||||
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
||||
|
||||
|
||||
**DO NOT FILE DUPLICATE REQUESTS.**
|
||||
|
||||
- type: textarea
|
||||
|
||||
86
.github/workflows/build-image.yml
vendored
86
.github/workflows/build-image.yml
vendored
@@ -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 }}
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '2'
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
|
||||
@@ -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$
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
17
Makefile
17
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
133
api/dataservices/source/access_rules.go
Normal file
133
api/dataservices/source/access_rules.go
Normal 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
|
||||
}
|
||||
43
api/dataservices/source/normalize_source.go
Normal file
43
api/dataservices/source/normalize_source.go
Normal 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
|
||||
}
|
||||
74
api/dataservices/source/sanitize_git.go
Normal file
74
api/dataservices/source/sanitize_git.go
Normal 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
|
||||
}
|
||||
72
api/dataservices/source/sanitize_git_test.go
Normal file
72
api/dataservices/source/sanitize_git_test.go
Normal 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{})
|
||||
// }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
21
api/dataservices/source/user_context.go
Normal file
21
api/dataservices/source/user_context.go
Normal 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}
|
||||
}
|
||||
97
api/dataservices/source/validation_rules_test.go
Normal file
97
api/dataservices/source/validation_rules_test.go
Normal 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))
|
||||
// }
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
122
api/datastore/migrator/migrate_2_43_sources.go
Normal file
122
api/datastore/migrator/migrate_2_43_sources.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
5
api/gitops/sources/helpers_test.go
Normal file
5
api/gitops/sources/helpers_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package sources
|
||||
|
||||
import "github.com/portainer/portainer/api/dataservices/source"
|
||||
|
||||
var adminUserContext = source.InsecureNewAdminContext()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
7
api/gitops/workflows/helpers_test.go
Normal file
7
api/gitops/workflows/helpers_test.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
)
|
||||
|
||||
var adminUserContext = source.InsecureNewAdminContext()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
5
api/http/handler/customtemplates/helpers_test.go
Normal file
5
api/http/handler/customtemplates/helpers_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package customtemplates
|
||||
|
||||
import "github.com/portainer/portainer/api/dataservices/source"
|
||||
|
||||
var adminUserContext = source.InsecureNewAdminContext()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
101
api/http/handler/gitops/sources/update_access.go
Normal file
101
api/http/handler/gitops/sources/update_access.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user