Compare commits

...

50 Commits

Author SHA1 Message Date
LP B
179df06267 feat(app): rework private registries and support private registries in kubernetes EE-30 (#5131)
* feat(app): rework private registries and support private registries in kubernetes

[EE-30]

feat(api): backport private registries backend changes (#5072)

* feat(api/bolt): backport bolt changes

* feat(api/exec): backport exec changes

* feat(api/http): backport http/handler/dockerhub changes

* feat(api/http): backport http/handler/endpoints changes

* feat(api/http): backport http/handler/registries changes

* feat(api/http): backport http/handler/stacks changes

* feat(api/http): backport http/handler changes

* feat(api/http): backport http/proxy/factory/azure changes

* feat(api/http): backport http/proxy/factory/docker changes

* feat(api/http): backport http/proxy/factory/utils changes

* feat(api/http): backport http/proxy/factory/kubernetes changes

* feat(api/http): backport http/proxy/factory changes

* feat(api/http): backport http/security changes

* feat(api/http): backport http changes

* feat(api/internal): backport internal changes

* feat(api): backport api changes

* feat(api/kubernetes): backport kubernetes changes

* fix(api/http): changes on backend following backport

feat(app): backport private registries frontend changes (#5056)

* feat(app/docker): backport docker/components changes

* feat(app/docker): backport docker/helpers changes

* feat(app/docker): backport docker/views/container changes

* feat(app/docker): backport docker/views/images changes

* feat(app/docker): backport docker/views/registries changes

* feat(app/docker): backport docker/views/services changes

* feat(app/docker): backport docker changes

* feat(app/kubernetes): backport kubernetes/components changes

* feat(app/kubernetes): backport kubernetes/converters changes

* feat(app/kubernetes): backport kubernetes/models changes

* feat(app/kubernetes): backport kubernetes/registries changes

* feat(app/kubernetes): backport kubernetes/services changes

* feat(app/kubernetes): backport kubernetes/views/applications changes

* feat(app/kubernetes): backport kubernetes/views/configurations changes

* feat(app/kubernetes): backport kubernetes/views/configure changes

* feat(app/kubernetes): backport kubernetes/views/resource-pools changes

* feat(app/kubernetes): backport kubernetes/views changes

* feat(app/portainer): backport portainer/components/accessManagement changes

* feat(app/portainer): backport portainer/components/datatables changes

* feat(app/portainer): backport portainer/components/forms changes

* feat(app/portainer): backport portainer/components/registry-details changes

* feat(app/portainer): backport portainer/models changes

* feat(app/portainer): backport portainer/rest changes

* feat(app/portainer): backport portainer/services changes

* feat(app/portainer): backport portainer/views changes

* feat(app/portainer): backport portainer changes

* feat(app): backport app changes

* config(project): gitignore + jsconfig changes

gitignore all files under api/cmd/portainer but main.go and enable Code Editor autocomplete on import ... from '@/...'

fix(app): fix pull rate limit checker

fix(app/registries): sidebar menus and registry accesses users filtering

fix(api): add missing kube client factory

fix(kube): fetch dockerhub pull limits (#5133)

fix(app): pre review fixes (#5142)

* fix(app/registries): remove checkbox for endpointRegistries view

* fix(endpoints): allow access to default namespace

* fix(docker): fetch pull limits

* fix(kube/ns): show selected registries for non admin

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>

chore(webpack): ignore missing sourcemaps

fix(registries): fetch registry config from url

feat(kube/registries): ignore not found when deleting secret

feat(db): move migration to db 31

fix(registries): fix bugs in PR EE-869 (#5169)

* fix(registries): hide role

* fix(endpoints): set empty access policy to edge endpoint

* fix(registry): remove double arguments

* fix(admin): ignore warning

* feat(kube/configurations): tag registry secrets (#5157)

* feat(kube/configurations): tag registry secrets

* feat(kube/secrets): show registry secrets for admins

* fix(registries): move dockerhub to beginning

* refactor(registries): use endpoint scoped registries

feat(registries): filter by namespace if supplied

feat(access-managment): filter users for registry (#5191)

* refactor(access-manage): move users selector to component

* feat(access-managment): filter users for registry

refactor(registries): sync code with CE (#5200)

* refactor(registry): add inspect handler under endpoints

* refactor(endpoint): sync endpoint_registries_list

* refactor(endpoints): sync registry_access

* fix(db): rename migration functions

* fix(registries): show accesses for admin

* fix(kube): set token on transport

* refactor(kube): move secret help to bottom

* fix(kuberentes): remove shouldLog parameter

* style(auth): add description of security.IsAdmin

* feat(security): allow admin access to registry

* feat(edge): connect to edge endpoint when creating client

* style(portainer): change deprecation version

* refactor(sidebar): hide manage

* refactor(containers): revert changes

* style(container): remove whitespace

* fix(endpoint): add handler to registy on endpointService

* refactor(image): use endpointService.registries

* fix(kueb/namespaces): rename resource pool to namespace

* fix(kube/namespace): move selected registries

* fix(api/registries): hide accesses on registry creation

Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>

refactor(api): remove code duplication after rebase

fix(app/registries): replace last registry api usage by endpoint registry api

fix(api/endpoints): update registry access policies on endpoint deletion (#5226)

[EE-1027]

fix(db): update db version

* fix(dockerhub): fetch rate limits

* fix(registry/tests): supply restricred context

* fix(registries): show proget registry only when selected

* fix(registry): create dockerhub registry

* feat(db): move migrations to db 32

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-07-14 21:15:21 +12:00
Dmitry Salakhov
0f5407da40 feat(tech): bump golang to v1.16 EE-515 (#4993)
* bump golang to v1.16

* Update build/linux/toolkit.Dockerfile

Co-authored-by: dbuduev <dbuduev@gmail.com>
2021-07-14 13:10:42 +12:00
Chaim Lev-Ari
2fd95d87eb fix(volumes): fetch resource by docker name (#5216) 2021-07-13 18:09:58 +12:00
cong meng
33b428eb7f EE-1110 Ingress routes and their mapping to a application name are not deleted when the application is deleted (#5291)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-07-09 10:41:04 +12:00
Chaim Lev-Ari
c6b770d697 feat(edgestack): remove deploy message (#5279)
[EE-392]
2021-07-08 11:39:52 +12:00
fhanportainer
d48f6bd02c fix(ingress): fixed hostname field when having multiple ingresses EE-1072 (#5273) 2021-07-05 18:17:20 +12:00
Stéphane Busso
340805f880 fix download logs (#5243) 2021-07-05 11:10:10 +12:00
zees-dev
f6c5c552aa feat(oauth/team-memberships): oauth team memberships teaser EE-341 (#5088)
* EE oauth team memberships feature teaser

* bugfix: deleting a default team should reset default team id to 0

* error wrapping, refactor team deletion code
2021-07-02 18:20:10 +12:00
dbuduev
90a472c08b feat(registry): Add ProGet registry type EE-703 (#5196)
* intermediate commit

* feat(registry): backport ProGet registry to CE (#954)

* backport EE changes

* label updates and remove auth-toggle

Co-authored-by: Dennis Buduev <dennis.buduev@portainer.io>
2021-07-01 14:57:15 +12:00
Richard Wei
8b80eb1731 fix(app):Set resource assignment default to off EE-1043 (#5248)
test passed.
2021-06-30 19:15:28 +12:00
yi-portainer
d2404458ea Merge branch 'release/2.6' into develop 2021-06-25 00:02:42 +12:00
Chaim Lev-Ari
1ddf76dbda fix(git-form): show git form and clear auth values (#5224)
* fix(custom-templates): show git form

fix [EE-1025]

* fix(git-form): empty auth values when auth is off
2021-06-23 12:33:22 +12:00
Chaim Lev-Ari
6a39a5cf44 fix(git-form): show git form and clear auth values (#5224)
* fix(custom-templates): show git form

fix [EE-1025]

* fix(git-form): empty auth values when auth is off
2021-06-22 21:41:50 +12:00
cong meng
a13ad8927f fix(stack) ignore username and password when authentication is disabled EE-161 (#5222)
* fix(stack) ignore username and password when authentication is disabled EE-161

* fix(stack) ignore username and password when authentication is disabled for stack creation EE-161

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-22 19:59:05 +12:00
cong meng
8e3751d0b7 fix(stack) Unable to update and redeploy a stack created from a git repository if it has failed once EE-1012 (#5212)
testing passed
2021-06-22 12:58:54 +12:00
Dmitry Salakhov
89f53458c6 fix(stack): allow standard users use advanced deployment (#5205) 2021-06-21 09:53:48 +12:00
cong meng
5466e68f50 fix(ACI): At least one team or user should be specified when creating a restricted container in Azure ACI EE-578 (#5204)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-18 11:30:18 +12:00
Stéphane Busso
60ef6d0270 Bump version to 2.6.0 2021-06-17 16:55:11 +12:00
Hui
caa6c15032 feat(k8s): advanced deployment from Git repo EE-447 (#5166)
* feat(stack): UI updates in git repo deployment method for k8s EE-640. (#5097)

* feat(stack): UI updates in git repo deployment method for k8s EE-640.

* feat(stack): supports the combination of GIT + COMPOSE.

* feat(stack): rename variable

* feat(stack): add git repo deployment method for k8s EE-638

* cleanup

* update payload validation rules

* make repo ref optional in frond end

Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
2021-06-16 23:47:32 +02:00
cong meng
6b759438b8 fix(k8s) cleaning up namespace access policies when removing users orteams from endpoint or endpoint group EE-718 (#5184)
* fix(k8s) cleaning up namespace access policies when removing users or teams from endpoint or endpoint group EE-718

* fix(k8s) minor code cleanup EE-718

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-16 20:15:29 +12:00
Hui
2170ad49ef fix(DB): downgrade DB version from 31 to 30 EE-955 (#5193)
* downgrade DB version from 31 to 30

* rename unit test func

* refactor migration func for DB 30

* move test helper func

* use struct method
2021-06-16 19:58:30 +12:00
yi-portainer
6a88c2ae36 Merge branch 'release/2.5' into develop 2021-06-16 17:31:00 +12:00
Alice Groux
7f96220a09 feat(k8s/advanced-deployment): allow standard users to see and use advanced deployment feature EE-446 (#5050) 2021-06-16 17:28:44 +12:00
Dmitry Salakhov
0b93714de4 feat(stacks): redeploy git stack [EE-161] (#5139)
* feat(git): save git config when creating stack (#5048)

* feat(git): save git config when creating stack

* chore(fs): test fileExists

* fix(git): fix tests to use CloneRepository

* refactor(git): move options to new object

* feat(stacks): redeploy git stack api (#5112)

* feat(stacks): redeploy git stacks form

[EE-666]

* feat(stack): show loading after confirmation

* fix(stacks): show same size description

* fix(stacks): reload state when deployed

* feat(stacks): set stopped stacks status to activate when updating

* feat(stacks): backup stack folder before cloning

* feat(stacks): don't accept prune and env on update git

Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-06-16 09:11:35 +12:00
cong meng
296ecc5960 fix(k8s) Adding a Kube app does not allow Global to be set after removing persisted folder EE-563 (#5143)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-15 15:53:31 +12:00
Chaim Lev-Ari
d7bc4f9b96 fix(stacks): show missing status stacks (#5047)
Co-authored-by: dbuduev <dbuduev@gmail.com>
2021-06-14 14:40:00 +02:00
itsconquest
a5e8cf62d2 feat(UX): introduce new env variables UI (#4175)
* feat(app): introduce new env vars ui

feat(app): introduce new env vars ui

feat(UX): WIP new env variables UI

feat(UX): update button and placeholder

feat(UX): mention .env file in message

feat(UX): allow add/remove value & load correctly

feat(UX): restrict filesize to 1MB

feat(UX): vertical align error message

feat(UX): fill UI from file & when switching modes

feat(UX): strip un-needed newline character

feat(UX): introduce component to other views

feat(UX): fix title alignment

feat(UX): only populate editor on mode switch when key exists

feat(UX): prevent trimming of whitespace on values

feat(UX): change editor to async

feat(UX): add message describing use

feat(UX): Refactor variable text to editorText

refactor(app): rename env vars controller

refactor(app): move env var explanation to parent

refactor(app): order env var panels

refactor(app): move simple env vars mode to component

refactor(app): parse env vars

refactor(app): move styles to css

refactor(app): rename functions

refactor(container): parse env vars

refactor(env-vars): move utils to helper module

refactor(env-vars): use util function for parse dot env file

fix(env-vars): ignore comments

refactor(services): use env vars utils

refactor(env-vars): rename files

refactor(env-panel): use utils

style(stack): revert EnvContent to Env

style(service): revert EnvContent to Env

style(container): revert EnvContent to Env

refactor(env-vars): support default value

refactor(service): use new env var component

refactor(env-var): use one way data flow

refactor(containers): remove unused function

* fix(env-vars): prevent using non .env files

* refactor(env-vars): move env vars items to a component

* feat(app): fixed env vars form validation in Stack

* feat(services): disable env form submit if invalid

* fix(app): show key pairs correctly

* fix(env-var): use the same validation as with kubernetes

* fix(env-vars): parse env var

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
2021-06-14 18:59:07 +12:00
zees-dev
6e9f472723 feat(container-stats): introduce container block I/O stats (#5017)
* feat(container-stats):introduce container block io stats

* Change charts to 2x2 view

* fix(container-stats): handle missing io stats by detecting stats based on op codes

Co-authored-by: DarkAEther <30438425+DarkAEther@users.noreply.github.com>
2021-06-14 15:57:00 +12:00
Hui
49bd139466 fix swagger param (#5183) 2021-06-14 14:45:57 +12:00
cong meng
dc180d85c5 Feat 4612 real time metrics for kube nodes (#4708)
* feat(k8s/node): display realtime node metrics GH#4612

* feat(k8s): show observation timestamp instead of real timestamp GH#4612

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-14 12:29:41 +12:00
Maxime Bajeux
45ceece1a9 feat(application): Invalid environment variable form validation when creating an application (#5019) 2021-06-14 11:06:54 +12:00
Chaim Lev-Ari
0b85684168 fix(app): parse response with null body (#4654)
* fix(app): parse response with null body

* style(docker): add comment explaining change

* fix(images): show correct error when failing import

* fix(images): use async await
2021-06-11 12:05:54 +12:00
Hui
f674573cdf feat(OAuth): Add SSO support for OAuth EE-390 (#5087)
* add updateSettingsToDB28 func and test

* update DBversion const

* migration func naming modification

* feat(oauth): add sso, hide internal auth teaser and logout options. (#5039)

* cleanup and make helper func for unit testing

* dbversion update

* feat(publicSettings): public settings response modification for OAuth SSO EE-608 (#5062)

* feat(oauth): updated logout logic with logoutUrl. (#5064)

* add exclusive token generation for OAuth

* swagger annotation revision

* add unit test

* updates based on tech review feedback

* feat(oauth): updated oauth settings model

* feat(oauth): added oauth logout url

* feat(oauth): fixed SSO toggle and logout issue.

* set SSO to ON by default

* update migrator unit test

* set SSO to true by default for new instance

* prevent applying the SSO logout url to the initial admin user

Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
2021-06-11 10:09:04 +12:00
Richard Wei
14ac005627 fix(app):fix local k8s endpoint not saved EE-825 (#5162) 2021-06-11 09:36:17 +12:00
cong meng
26ead28d7b Feat(stacks): orphaned stacks #4397 (#4834)
* feat(stack): add the ability for an administrator user to manage orphaned stacks (#4397)

* feat(stack): apply small font size to the information text of associate (#4397)

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-10 14:52:33 +12:00
zees-dev
eae2f5c9fc feat(kubernetes/summary): summary of k8s actions upon deploying/updating resources EE-436 (#5137)
* feat EE-440/EE-436 kubernetes-resources-summary-panel

* bugfix: returning created resources after update

* fixed patch based bugs - displaying accurate updates for k8s resources

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-10 10:38:23 +12:00
cong meng
1f2a90a722 fix(frontend): When a docker endpoint is selected, configuring a newly added k8s agent fails EE-821 (#5115)
* fix(frontend): When a docker endpoint is selected, configuring a newly added k8s agent fails EE-821

* fix(frontend): restore endpointID in a finally block EE-821

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-09 21:54:36 +02:00
fhanportainer
267968e099 fix(aci): fixed aci with persistence or networking issue. (#4996) 2021-06-10 01:34:19 +12:00
cong meng
defd929366 Fix(kube) advanced deployment CE-83 (#4866)
* refactor(http/kube): convert compose format

* feat(kube/deploy): deploy to agent

* feat(kube/deploy): show more details about error

* refactor(kube): return string from deploy

* feat(kube/deploy): revert to use local kubectl

* Revert "feat(kube/deploy): revert to use local kubectl"

This reverts commit 7c4a1c70

* feat(kube/deploy): GH#4321 use the v2 version of agent api instead of v3

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-09 01:55:17 +02:00
testA113
2fb17c9cf9 Merge pull request #4983 from portainer/feat/EE-352/CE-truncate-image-name-in-tables
feat(k8s): truncate image name in tables
2021-06-04 15:20:26 +12:00
dbuduev
c8d78ad15f Merge pull request #5146 from portainer/feat/EE-872/test-scaffolding
feat(bolt): implement bolt db test store EE-872
2021-06-04 13:44:56 +12:00
Dennis Buduev
96a6129d8a feat(bolt): implement boltdb test store EE-872 2021-06-04 13:33:18 +12:00
Alice Groux
b8660ed2a0 feat(k8s/applications): reorder placement policies and select mandatory by default (#5063) 2021-06-03 13:42:44 +02:00
Chaim Lev-Ari
9ec1f2ed6d fix(endpoints): set sysctl setting for new endpoints (#5028) 2021-06-03 11:36:54 +02:00
yi-portainer
8bfa5132cd Merge branch 'release/2.5' into develop 2021-06-03 20:39:54 +12:00
wheresolivia
cafcebe27e Merge pull request #4668 from portainer/feat-4667-custom-portainer-folder
chore(dev-build): custom portainer data folder
2021-06-03 13:28:33 +12:00
yi-portainer
1d46f2bb35 * update portainer version to 2.5.1 2021-05-28 10:21:29 +12:00
alice groux
872a8262f1 feat(k8s): add full name on hovering over the image name 2021-04-14 14:59:17 +02:00
alice groux
c339afb562 feat(k8s): cut image name to 64 chars with truncate filter in all applications datatables 2021-04-13 16:09:37 +02:00
Chaim Lev-Ari
78661b50ca chore(dev-build): custom portainer data folder 2021-04-12 08:49:07 +03:00
327 changed files with 8430 additions and 3705 deletions

View File

@@ -0,0 +1,73 @@
package bolttest
import (
"io/ioutil"
"log"
"os"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/bolt"
"github.com/portainer/portainer/api/filesystem"
)
var errTempDir = errors.New("can't create a temp dir")
func MustNewTestStore(init bool) (*bolt.Store, func()) {
store, teardown, err := NewTestStore(init)
if err != nil {
if !errors.Is(err, errTempDir) {
teardown()
}
log.Fatal(err)
}
return store, teardown
}
func NewTestStore(init bool) (*bolt.Store, func(), error) {
// Creates unique temp directory in a concurrency friendly manner.
dataStorePath, err := ioutil.TempDir("", "boltdb")
if err != nil {
return nil, nil, errors.Wrap(errTempDir, err.Error())
}
fileService, err := filesystem.NewService(dataStorePath, "")
if err != nil {
return nil, nil, err
}
store, err := bolt.NewStore(dataStorePath, fileService)
if err != nil {
return nil, nil, err
}
err = store.Open()
if err != nil {
return nil, nil, err
}
if init {
err = store.Init()
if err != nil {
return nil, nil, err
}
}
teardown := func() {
teardown(store, dataStorePath)
}
return store, teardown, nil
}
func teardown(store *bolt.Store, dataStorePath string) {
err := store.Close()
if err != nil {
log.Fatalln(err)
}
err = os.RemoveAll(dataStorePath)
if err != nil {
log.Fatalln(err)
}
}

View File

@@ -169,6 +169,7 @@ func (store *Store) MigrateData(force bool) error {
UserService: store.UserService,
VersionService: store.VersionService,
FileService: store.fileService,
DockerhubService: store.DockerHubService,
AuthorizationService: authorization.NewService(store),
}
migrator := migrator.NewMigrator(migratorParams)

View File

@@ -55,22 +55,6 @@ func (store *Store) Init() error {
return err
}
_, err = store.DockerHubService.DockerHub()
if err == errors.ErrObjectNotFound {
defaultDockerHub := &portainer.DockerHub{
Authentication: false,
Username: "",
Password: "",
}
err := store.DockerHubService.UpdateDockerHub(defaultDockerHub)
if err != nil {
return err
}
} else if err != nil {
return err
}
groups, err := store.EndpointGroupService.EndpointGroups()
if err != nil {
return err

41
api/bolt/log/log.go Normal file
View File

@@ -0,0 +1,41 @@
package log
import (
"fmt"
"log"
)
const (
INFO = "INFO"
ERROR = "ERROR"
DEBUG = "DEBUG"
FATAL = "FATAL"
)
type ScopedLog struct {
scope string
}
func NewScopedLog(scope string) *ScopedLog {
return &ScopedLog{scope: scope}
}
func (slog *ScopedLog) print(kind string, message string) {
log.Printf("[%s] [%s] %s", kind, slog.scope, message)
}
func (slog *ScopedLog) Debug(message string) {
slog.print(DEBUG, fmt.Sprintf("[message: %s]", message))
}
func (slog *ScopedLog) Info(message string) {
slog.print(INFO, fmt.Sprintf("[message: %s]", message))
}
func (slog *ScopedLog) Error(message string, err error) {
slog.print(ERROR, fmt.Sprintf("[message: %s] [error: %s]", message, err))
}
func (slog *ScopedLog) NotImplemented(method string) {
log.Fatalf("[%s] [%s] [%s]", FATAL, slog.scope, fmt.Sprintf("%s is not yet implemented", method))
}

1
api/bolt/log/log.test.go Normal file
View File

@@ -0,0 +1 @@
package log

View File

@@ -0,0 +1,18 @@
package migrator
func (m *Migrator) migrateDBVersionTo30() error {
if err := m.migrateSettings(); err != nil {
return err
}
return nil
}
func (m *Migrator) migrateSettings() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
legacySettings.OAuthSettings.SSO = false
legacySettings.OAuthSettings.LogoutURI = ""
return m.settingsService.UpdateSettings(legacySettings)
}

View File

@@ -0,0 +1,95 @@
package migrator
import (
"os"
"path"
"testing"
"time"
"github.com/boltdb/bolt"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/portainer/portainer/api/bolt/settings"
)
var (
testingDBStorePath string
testingDBFileName string
dummyLogoURL string
dbConn *bolt.DB
settingsService *settings.Service
)
// initTestingDBConn creates a raw bolt DB connection
// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg
func initTestingDBConn(storePath, fileName string) (*bolt.DB, error) {
databasePath := path.Join(storePath, fileName)
dbConn, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return nil, err
}
return dbConn, nil
}
// initTestingDBConn creates a settings service with raw bolt DB connection
// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg
func initTestingSettingsService(dbConn *bolt.DB, preSetObj map[string]interface{}) (*settings.Service, error) {
internalDBConn := &internal.DbConnection{
DB: dbConn,
}
settingsService, err := settings.NewService(internalDBConn)
if err != nil {
return nil, err
}
//insert a obj
if err := internal.UpdateObject(internalDBConn, "settings", []byte("SETTINGS"), preSetObj); err != nil {
return nil, err
}
return settingsService, nil
}
func setup() error {
testingDBStorePath, _ = os.Getwd()
testingDBFileName = "portainer-ee-mig-30.db"
dummyLogoURL = "example.com"
var err error
dbConn, err = initTestingDBConn(testingDBStorePath, testingDBFileName)
if err != nil {
return err
}
dummySettingsObj := map[string]interface{}{
"LogoURL": dummyLogoURL,
}
settingsService, err = initTestingSettingsService(dbConn, dummySettingsObj)
if err != nil {
return err
}
return nil
}
func TestMigrateSettings(t *testing.T) {
if err := setup(); err != nil {
t.Errorf("failed to complete testing setups, err: %v", err)
}
defer dbConn.Close()
defer os.Remove(testingDBFileName)
m := &Migrator{
db: dbConn,
settingsService: settingsService,
}
if err := m.migrateSettings(); err != nil {
t.Errorf("failed to update settings: %v", err)
}
updatedSettings, err := m.settingsService.Settings()
if err != nil {
t.Errorf("failed to retrieve the updated settings: %v", err)
}
if updatedSettings.LogoURL != dummyLogoURL {
t.Errorf("unexpected value changes in the updated settings, want LogoURL value: %s, got LogoURL value: %s", dummyLogoURL, updatedSettings.LogoURL)
}
if updatedSettings.OAuthSettings.SSO != false {
t.Errorf("unexpected default OAuth SSO setting, want: false, got: %t", updatedSettings.OAuthSettings.SSO)
}
if updatedSettings.OAuthSettings.LogoutURI != "" {
t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI)
}
}

View File

@@ -0,0 +1,124 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
)
func (m *Migrator) migrateDBVersionTo32() error {
err := m.updateRegistriesToDB32()
if err != nil {
return err
}
err = m.updateDockerhubToDB32()
if err != nil {
return err
}
return nil
}
func (m *Migrator) updateRegistriesToDB32() error {
registries, err := m.registryService.Registries()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, registry := range registries {
registry.RegistryAccesses = portainer.RegistryAccesses{}
for _, endpoint := range endpoints {
filteredUserAccessPolicies := portainer.UserAccessPolicies{}
for userId, registryPolicy := range registry.UserAccessPolicies {
if _, found := endpoint.UserAccessPolicies[userId]; found {
filteredUserAccessPolicies[userId] = registryPolicy
}
}
filteredTeamAccessPolicies := portainer.TeamAccessPolicies{}
for teamId, registryPolicy := range registry.TeamAccessPolicies {
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
filteredTeamAccessPolicies[teamId] = registryPolicy
}
}
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{
UserAccessPolicies: filteredUserAccessPolicies,
TeamAccessPolicies: filteredTeamAccessPolicies,
Namespaces: []string{},
}
}
m.registryService.UpdateRegistry(registry.ID, &registry)
}
return nil
}
func (m *Migrator) updateDockerhubToDB32() error {
dockerhub, err := m.dockerhubService.DockerHub()
if err == errors.ErrObjectNotFound {
return nil
} else if err != nil {
return err
}
if !dockerhub.Authentication {
return nil
}
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
Name: "Dockerhub (authenticated - migrated)",
URL: "docker.io",
Authentication: true,
Username: dockerhub.Username,
Password: dockerhub.Password,
RegistryAccesses: portainer.RegistryAccesses{},
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpoint.Type != portainer.KubernetesLocalEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment &&
endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment {
userAccessPolicies := portainer.UserAccessPolicies{}
for userId := range endpoint.UserAccessPolicies {
if _, found := endpoint.UserAccessPolicies[userId]; found {
userAccessPolicies[userId] = portainer.AccessPolicy{
RoleID: 0,
}
}
}
teamAccessPolicies := portainer.TeamAccessPolicies{}
for teamId := range endpoint.TeamAccessPolicies {
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
teamAccessPolicies[teamId] = portainer.AccessPolicy{
RoleID: 0,
}
}
}
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{
UserAccessPolicies: userAccessPolicies,
TeamAccessPolicies: teamAccessPolicies,
Namespaces: []string{},
}
}
}
return m.registryService.CreateRegistry(registry)
}

View File

@@ -3,10 +3,12 @@ package migrator
import (
"github.com/boltdb/bolt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/dockerhub"
"github.com/portainer/portainer/api/bolt/endpoint"
"github.com/portainer/portainer/api/bolt/endpointgroup"
"github.com/portainer/portainer/api/bolt/endpointrelation"
"github.com/portainer/portainer/api/bolt/extension"
plog "github.com/portainer/portainer/api/bolt/log"
"github.com/portainer/portainer/api/bolt/registry"
"github.com/portainer/portainer/api/bolt/resourcecontrol"
"github.com/portainer/portainer/api/bolt/role"
@@ -20,6 +22,8 @@ import (
"github.com/portainer/portainer/api/internal/authorization"
)
var migrateLog = plog.NewScopedLog("bolt, migrate")
type (
// Migrator defines a service to migrate data after a Portainer version update.
Migrator struct {
@@ -41,6 +45,7 @@ type (
versionService *version.Service
fileService portainer.FileService
authorizationService *authorization.Service
dockerhubService *dockerhub.Service
}
// Parameters represents the required parameters to create a new Migrator instance.
@@ -63,6 +68,7 @@ type (
VersionService *version.Service
FileService portainer.FileService
AuthorizationService *authorization.Service
DockerhubService *dockerhub.Service
}
)
@@ -87,6 +93,7 @@ func NewMigrator(parameters *Parameters) *Migrator {
versionService: parameters.VersionService,
fileService: parameters.FileService,
authorizationService: parameters.AuthorizationService,
dockerhubService: parameters.DockerhubService,
}
}
@@ -358,5 +365,21 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 2.6.0
if m.currentDBVersion < 30 {
err := m.migrateDBVersionTo30()
if err != nil {
return err
}
}
// Portainer 2.9.0
if m.currentDBVersion < 32 {
err := m.migrateDBVersionTo32()
if err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion)
}

View File

@@ -167,11 +167,6 @@ func (store *Store) CustomTemplate() portainer.CustomTemplateService {
return store.CustomTemplateService
}
// DockerHub gives access to the DockerHub data management layer
func (store *Store) DockerHub() portainer.DockerHubService {
return store.DockerHubService
}
// EdgeGroup gives access to the EdgeGroup data management layer
func (store *Store) EdgeGroup() portainer.EdgeGroupService {
return store.EdgeGroupService

View File

@@ -20,6 +20,7 @@ import (
"github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/http/proxy"
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/jwt"
@@ -88,8 +89,8 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
}
func initKubernetesDeployer(assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(assetsPath)
func initKubernetesDeployer(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(dataStore, reverseTunnelService, signatureService, assetsPath)
}
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
@@ -133,8 +134,8 @@ func initDockerClientFactory(signatureService portainer.DigitalSignatureService,
return docker.NewClientFactory(signatureService, reverseTunnelService)
}
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *kubecli.ClientFactory {
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID)
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *kubecli.ClientFactory {
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID, dataStore)
}
func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) {
@@ -165,6 +166,7 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
settings.SnapshotInterval = *flags.SnapshotInterval
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
settings.EnableTelemetry = true
settings.OAuthSettings.SSO = true
if *flags.Templates != "" {
settings.TemplatesURL = *flags.Templates
@@ -240,6 +242,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.Dat
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
@@ -301,6 +304,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore,
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
@@ -378,7 +382,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID)
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID, dataStore)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx)
if err != nil {
@@ -386,6 +390,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
snapshotService.Start()
authorizationService := authorization.NewService(dataStore)
authorizationService.K8sClientFactory = kubernetesClientFactory
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService)
if err != nil {
log.Fatalf("failed initializing swarm stack manager: %v", err)
@@ -395,7 +402,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
kubernetesDeployer := initKubernetesDeployer(*flags.Assets)
kubernetesDeployer := initKubernetesDeployer(dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
if dataStore.IsNew() {
err = updateSettingsFromFlags(dataStore, flags)
@@ -458,6 +465,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
return &http.Server{
AuthorizationService: authorizationService,
ReverseTunnelService: reverseTunnelService,
Status: applicationStatus,
BindAddress: *flags.Addr,

View File

@@ -2,71 +2,188 @@ package exec
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os/exec"
"path"
"runtime"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
)
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment.
type KubernetesDeployer struct {
binaryPath string
binaryPath string
dataStore portainer.DataStore
reverseTunnelService portainer.ReverseTunnelService
signatureService portainer.DigitalSignatureService
}
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
func NewKubernetesDeployer(binaryPath string) *KubernetesDeployer {
func NewKubernetesDeployer(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
return &KubernetesDeployer{
binaryPath: binaryPath,
binaryPath: binaryPath,
dataStore: datastore,
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
}
}
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint.
// If composeFormat is set to true, it will leverage the kompose binary to deploy a compose compliant manifest.
// Otherwise it will use kubectl to deploy the manifest.
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) {
if composeFormat {
convertedData, err := deployer.convertComposeData(data)
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
if endpoint.Type == portainer.KubernetesLocalEnvironment {
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
if err != nil {
return nil, err
return "", err
}
data = string(convertedData)
command := path.Join(deployer.binaryPath, "kubectl")
if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kubectl.exe")
}
args := make([]string, 0)
args = append(args, "--server", endpoint.URL)
args = append(args, "--insecure-skip-tls-verify")
args = append(args, "--token", string(token))
args = append(args, "--namespace", namespace)
args = append(args, "apply", "-f", "-")
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
cmd.Stdin = strings.NewReader(stackConfig)
output, err := cmd.Output()
if err != nil {
return "", errors.New(stderr.String())
}
return string(output), nil
}
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
// agent
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
tunnel := deployer.reverseTunnelService.GetTunnelDetails(endpoint.ID)
if tunnel.Status == portainer.EdgeAgentIdle {
err := deployer.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return "", err
}
settings, err := deployer.dataStore.Settings().Settings()
if err != nil {
return "", err
}
waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second
time.Sleep(waitForAgentToConnect * 2)
}
endpointURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
}
transport := &http.Transport{}
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return "", err
}
transport.TLSClientConfig = tlsConfig
}
httpCli := &http.Client{
Transport: transport,
}
if !strings.HasPrefix(endpointURL, "http") {
endpointURL = fmt.Sprintf("https://%s", endpointURL)
}
url, err := url.Parse(fmt.Sprintf("%s/v2/kubernetes/stack", endpointURL))
if err != nil {
return nil, err
return "", err
}
command := path.Join(deployer.binaryPath, "kubectl")
if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kubectl.exe")
}
args := make([]string, 0)
args = append(args, "--server", endpoint.URL)
args = append(args, "--insecure-skip-tls-verify")
args = append(args, "--token", string(token))
args = append(args, "--namespace", namespace)
args = append(args, "apply", "-f", "-")
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
cmd.Stdin = strings.NewReader(data)
output, err := cmd.Output()
reqPayload, err := json.Marshal(
struct {
StackConfig string
Namespace string
}{
StackConfig: stackConfig,
Namespace: namespace,
})
if err != nil {
return nil, errors.New(stderr.String())
return "", err
}
return output, nil
req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqPayload))
if err != nil {
return "", err
}
signature, err := deployer.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return "", err
}
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
resp, err := httpCli.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errorResponseData struct {
Message string
Details string
}
err = json.NewDecoder(resp.Body).Decode(&errorResponseData)
if err != nil {
output, parseStringErr := ioutil.ReadAll(resp.Body)
if parseStringErr != nil {
return "", parseStringErr
}
return "", fmt.Errorf("Failed parsing, body: %s, error: %w", output, err)
}
return "", fmt.Errorf("Deployment to agent failed: %s", errorResponseData.Details)
}
var responseData struct{ Output string }
err = json.NewDecoder(resp.Body).Decode(&responseData)
if err != nil {
parsedOutput, parseStringErr := ioutil.ReadAll(resp.Body)
if parseStringErr != nil {
return "", parseStringErr
}
return "", fmt.Errorf("Failed decoding, body: %s, err: %w", parsedOutput, err)
}
return responseData.Output, nil
}
func (deployer *KubernetesDeployer) convertComposeData(data string) ([]byte, error) {
// ConvertCompose leverages the kompose binary to deploy a compose compliant manifest.
func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) {
command := path.Join(deployer.binaryPath, "kompose")
if runtime.GOOS == "windows" {
command = path.Join(deployer.binaryPath, "kompose.exe")

View File

@@ -10,7 +10,7 @@ import (
"path"
"runtime"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
)
// SwarmStackManager represents a service for managing stacks.
@@ -42,7 +42,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine
}
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) {
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) {
command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
for _, registry := range registries {
if registry.Authentication {
@@ -50,11 +50,6 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
}
}
if dockerhub.Authentication {
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "")
}
}
// Logout executes the docker logout command.

View File

@@ -31,6 +31,8 @@ const (
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
// ManifestFileDefaultName represents the default name of a k8s manifest file.
ManifestFileDefaultName = "k8s-deployment.yml"
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
EdgeStackStorePath = "edge_stacks"
// PrivateKeyFile represents the name on disk of the file containing the private key.
@@ -279,13 +281,7 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error
// FileExists checks for the existence of the specified file.
func (service *Service) FileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
return FileExists(filePath)
}
// KeyPairFilesExist checks for the existence of the key files.
@@ -510,3 +506,31 @@ func (service *Service) GetTemporaryPath() (string, error) {
func (service *Service) GetDatastorePath() string {
return service.dataStorePath
}
// FileExists checks for the existence of the specified file.
func FileExists(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
func MoveDirectory(originalPath, newPath string) error {
if _, err := os.Stat(originalPath); err != nil {
return err
}
alreadyExists, err := FileExists(newPath)
if err != nil {
return err
}
if alreadyExists {
return errors.New("Target path already exists")
}
return os.Rename(originalPath, newPath)
}

View File

@@ -0,0 +1,55 @@
package filesystem
import (
"fmt"
"math/rand"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
service := createService(t)
testHelperFileExists_fileExists(t, service.FileExists)
}
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
service := createService(t)
testHelperFileExists_fileNotExists(t, service.FileExists)
}
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
testHelperFileExists_fileExists(t, FileExists)
}
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
testHelperFileExists_fileNotExists(t, FileExists)
}
func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) {
file, err := os.CreateTemp("", t.Name())
assert.NoError(t, err, "CreateTemp should not fail")
t.Cleanup(func() {
os.RemoveAll(file.Name())
})
exists, err := checker(file.Name())
assert.NoError(t, err, "FileExists should not fail")
assert.True(t, exists)
}
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
filePath := path.Join(os.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
err := os.RemoveAll(filePath)
assert.NoError(t, err, "RemoveAll should not fail")
exists, err := checker(filePath)
assert.NoError(t, err, "FileExists should not fail")
assert.False(t, exists)
}

View File

@@ -0,0 +1,49 @@
package filesystem
import (
"fmt"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
// temporary function until upgrade to 1.16
func tempDir(t *testing.T) string {
tmpDir, err := os.MkdirTemp("", "dir")
assert.NoError(t, err, "MkdirTemp should not fail")
return tmpDir
}
func Test_movePath_shouldFailIfOriginalPathDoesntExist(t *testing.T) {
tmpDir := tempDir(t)
missingPath := path.Join(tmpDir, "missing")
targetPath := path.Join(tmpDir, "target")
defer os.RemoveAll(tmpDir)
err := MoveDirectory(missingPath, targetPath)
assert.Error(t, err, "move directory should fail when target path exists")
}
func Test_movePath_shouldFailIfTargetPathDoesExist(t *testing.T) {
originalPath := tempDir(t)
missingPath := tempDir(t)
defer os.RemoveAll(originalPath)
defer os.RemoveAll(missingPath)
err := MoveDirectory(originalPath, missingPath)
assert.Error(t, err, "move directory should fail when target path exists")
}
func Test_movePath_success(t *testing.T) {
originalPath := tempDir(t)
defer os.RemoveAll(originalPath)
err := MoveDirectory(originalPath, fmt.Sprintf("%s-old", originalPath))
assert.NoError(t, err)
}

View File

@@ -0,0 +1,22 @@
package filesystem
import (
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
func createService(t *testing.T) *Service {
dataStorePath := path.Join(os.TempDir(), t.Name())
service, err := NewService(dataStorePath, "")
assert.NoError(t, err, "NewService should not fail")
t.Cleanup(func() {
os.RemoveAll(dataStorePath)
})
return service
}

View File

@@ -2,12 +2,13 @@ package git
import (
"fmt"
"github.com/docker/docker/pkg/ioutils"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
"os"
"path/filepath"
"testing"
"github.com/docker/docker/pkg/ioutils"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
)
func TestService_ClonePublicRepository_Azure(t *testing.T) {
@@ -54,7 +55,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
assert.NoError(t, err)
defer os.RemoveAll(dst)
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
err = service.ClonePublicRepository(repositoryUrl, tt.args.referenceName, dst)
err = service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "")
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
})
@@ -72,7 +73,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
defer os.RemoveAll(dst)
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, "", pat)
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", "", pat)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
}

View File

@@ -3,12 +3,13 @@ package git
import (
"context"
"crypto/tls"
"github.com/pkg/errors"
"net/http"
"os"
"path/filepath"
"time"
"github.com/pkg/errors"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport/client"
@@ -27,7 +28,7 @@ type downloader interface {
download(ctx context.Context, dst string, opt cloneOptions) error
}
type gitClient struct{
type gitClient struct {
preserveGitDirectory bool
}
@@ -86,26 +87,18 @@ func NewService() *Service {
}
}
// ClonePublicRepository clones a public git repository using the specified URL in the specified
// CloneRepository clones a git repository using the specified URL in the specified
// destination folder.
func (service *Service) ClonePublicRepository(repositoryURL, referenceName, destination string) error {
return service.cloneRepository(destination, cloneOptions{
repositoryUrl: repositoryURL,
referenceName: referenceName,
depth: 1,
})
}
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
// destination folder. It will use the specified Username and Password for basic HTTP authentication.
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName, destination, username, password string) error {
return service.cloneRepository(destination, cloneOptions{
func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string) error {
options := cloneOptions{
repositoryUrl: repositoryURL,
username: username,
password: password,
referenceName: referenceName,
depth: 1,
})
}
return service.cloneRepository(destination, options)
}
func (service *Service) cloneRepository(destination string, options cloneOptions) error {

View File

@@ -1,11 +1,12 @@
package git
import (
"github.com/docker/docker/pkg/ioutils"
"github.com/stretchr/testify/assert"
"os"
"path/filepath"
"testing"
"github.com/docker/docker/pkg/ioutils"
"github.com/stretchr/testify/assert"
)
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
@@ -20,7 +21,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
defer os.RemoveAll(dst)
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, username, pat)
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat)
assert.NoError(t, err)
assert.FileExists(t, filepath.Join(dst, "README.md"))
}

View File

@@ -2,16 +2,17 @@ package git
import (
"context"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
"github.com/stretchr/testify/assert"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
"github.com/stretchr/testify/assert"
)
var bareRepoDir string
@@ -59,7 +60,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
}
defer os.RemoveAll(dir)
t.Logf("Cloning into %s", dir)
err = service.ClonePublicRepository(repositoryURL, referenceName, dir)
err = service.CloneRepository(dir, repositoryURL, referenceName, "", "")
assert.NoError(t, err)
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
}
@@ -74,9 +75,11 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
if err != nil {
t.Fatalf("failed to create a temp dir")
}
defer os.RemoveAll(dir)
t.Logf("Cloning into %s", dir)
err = service.ClonePublicRepository(repositoryURL, referenceName, dir)
err = service.CloneRepository(dir, repositoryURL, referenceName, "", "")
assert.NoError(t, err)
assert.NoDirExists(t, filepath.Join(dir, ".git"))
}

7
api/git/types/types.go Normal file
View File

@@ -0,0 +1,7 @@
package gittypes
type RepoConfig struct {
URL string
ReferenceName string
ConfigFilePath string
}

View File

@@ -1,6 +1,6 @@
module github.com/portainer/portainer/api
go 1.13
go 1.16
require (
github.com/Microsoft/go-winio v0.4.16
@@ -34,6 +34,7 @@ require (
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
k8s.io/api v0.17.2
k8s.io/apimachinery v0.17.2
k8s.io/client-go v0.17.2

View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strings"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
@@ -130,19 +131,21 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
}
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
tokenData := &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}
return handler.persistAndWriteToken(w, composeTokenData(user))
}
return handler.persistAndWriteToken(w, tokenData)
func (handler *Handler) writeTokenForOAuth(w http.ResponseWriter, user *portainer.User, expiryTime *time.Time) *httperror.HandlerError {
token, err := handler.JWTService.GenerateTokenForOAuth(composeTokenData(user), expiryTime)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err}
}
return response.JSON(w, &authenticateResponse{JWT: token})
}
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
token, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err}
}
return response.JSON(w, &authenticateResponse{JWT: token})
@@ -204,3 +207,11 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM
}
return false
}
func composeTokenData(user *portainer.User) *portainer.TokenData {
return &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"log"
"net/http"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
@@ -25,7 +26,24 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
// @id AuthenticateOauth
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, *time.Time, error) {
if code == "" {
return "", nil, errors.New("Invalid OAuth authorization code")
}
if settings == nil {
return "", nil, errors.New("Invalid OAuth configuration")
}
username, expiryTime, err := handler.OAuthService.Authenticate(code, settings)
if err != nil {
return "", nil, err
}
return username, expiryTime, nil
}
// @id ValidateOAuth
// @summary Authenticate with OAuth
// @tags auth
// @accept json
@@ -36,52 +54,35 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
// @failure 422 "Invalid Credentials"
// @failure 500 "Server error"
// @router /auth/oauth/validate [post]
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
if code == "" {
return "", errors.New("Invalid OAuth authorization code")
}
if settings == nil {
return "", errors.New("Invalid OAuth configuration")
}
username, err := handler.OAuthService.Authenticate(code, settings)
if err != nil {
return "", err
}
return username, nil
}
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload oauthPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings from the database", Err: err}
}
if settings.AuthenticationMethod != 3 {
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")}
if settings.AuthenticationMethod != portainer.AuthenticationOAuth {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "OAuth authentication is not enabled", Err: errors.New("OAuth authentication is not enabled")}
}
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
username, expiryTime, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
if err != nil {
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to authenticate through OAuth", Err: httperrors.ErrUnauthorized}
}
user, err := handler.DataStore.User().UserByUsername(username)
if err != nil && err != bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a user with the specified username from the database", Err: err}
}
if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers {
return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Account not created beforehand in Portainer and automatic user provisioning not enabled", Err: httperrors.ErrUnauthorized}
}
if user == nil {
@@ -92,7 +93,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
err = handler.DataStore.User().CreateUser(user)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist user inside the database", Err: err}
}
if settings.OAuthSettings.DefaultTeamID != 0 {
@@ -104,11 +105,11 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
err = handler.DataStore.TeamMembership().CreateTeamMembership(membership)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist team membership inside the database", Err: err}
}
}
}
return handler.writeToken(w, user)
return handler.writeTokenForOAuth(w, user, expiryTime)
}

View File

@@ -236,16 +236,14 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID))
customTemplate.ProjectPath = projectPath
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
referenceName: payload.RepositoryReferenceName,
path: projectPath,
authentication: payload.RepositoryAuthentication,
username: payload.RepositoryUsername,
password: payload.RepositoryPassword,
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err = handler.cloneGitRepository(gitCloneParams)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return nil, err
}

View File

@@ -1,17 +0,0 @@
package customtemplates
type cloneRepositoryParameters struct {
url string
referenceName string
path string
authentication bool
username string
password string
}
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
if parameters.authentication {
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
}
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
}

View File

@@ -1,28 +0,0 @@
package dockerhub
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
)
// @id DockerHubInspect
// @summary Retrieve DockerHub information
// @description Use this endpoint to retrieve the information used to connect to the DockerHub
// @description **Access policy**: authenticated
// @tags dockerhub
// @security jwt
// @produce json
// @success 200 {object} portainer.DockerHub
// @failure 500 "Server error"
// @router /dockerhub [get]
func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
}
hideFields(dockerhub)
return response.JSON(w, dockerhub)
}

View File

@@ -1,68 +0,0 @@
package dockerhub
import (
"errors"
"net/http"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type dockerhubUpdatePayload struct {
// Enable authentication against DockerHub
Authentication bool `validate:"required" example:"false"`
// Username used to authenticate against the DockerHub
Username string `validate:"required" example:"hub_user"`
// Password used to authenticate against the DockerHub
Password string `validate:"required" example:"hub_password"`
}
func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
}
return nil
}
// @id DockerHubUpdate
// @summary Update DockerHub information
// @description Use this endpoint to update the information used to connect to the DockerHub
// @description **Access policy**: administrator
// @tags dockerhub
// @security jwt
// @accept json
// @produce json
// @param body body dockerhubUpdatePayload true "DockerHub information"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /dockerhub [put]
func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload dockerhubUpdatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
dockerhub := &portainer.DockerHub{
Authentication: false,
Username: "",
Password: "",
}
if payload.Authentication {
dockerhub.Authentication = true
dockerhub.Username = payload.Username
dockerhub.Password = payload.Password
}
err = handler.DataStore.DockerHub().UpdateDockerHub(dockerhub)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err}
}
return response.Empty(w)
}

View File

@@ -1,33 +0,0 @@
package dockerhub
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
)
func hideFields(dockerHub *portainer.DockerHub) {
dockerHub.Password = ""
}
// Handler is the HTTP handler used to handle DockerHub operations.
type Handler struct {
*mux.Router
DataStore portainer.DataStore
}
// NewHandler creates a handler to manage Dockerhub operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/dockerhub",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet)
h.Handle("/dockerhub",
bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut)
return h
}

View File

@@ -212,16 +212,14 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
referenceName: payload.RepositoryReferenceName,
path: projectPath,
authentication: payload.RepositoryAuthentication,
username: payload.RepositoryUsername,
password: payload.RepositoryPassword,
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err = handler.cloneGitRepository(gitCloneParams)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return nil, err
}

View File

@@ -1,17 +0,0 @@
package edgestacks
type cloneRepositoryParameters struct {
url string
referenceName string
path string
authentication bool
username string
password string
}
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
if parameters.authentication {
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
}
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
}

View File

@@ -109,12 +109,33 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque
}
}
updateAuthorizations := false
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) {
endpointGroup.UserAccessPolicies = payload.UserAccessPolicies
updateAuthorizations = true
}
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) {
endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies
updateAuthorizations = true
}
if updateAuthorizations {
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
}
for _, endpoint := range endpoints {
if endpoint.GroupID == endpointGroup.ID {
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(&endpoint, endpointGroup)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err}
}
}
}
}
}
err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup)

View File

@@ -1,6 +1,7 @@
package endpointgroups
import (
"github.com/portainer/portainer/api/internal/authorization"
"net/http"
"github.com/gorilla/mux"
@@ -12,6 +13,7 @@ import (
// Handler is the HTTP handler used to handle endpoint group operations.
type Handler struct {
*mux.Router
AuthorizationService *authorization.Service
DataStore portainer.DataStore
}

View File

@@ -156,7 +156,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @accept multipart/form-data
// @produce json
// @param Name formData string true "Name that will be used to identify this endpoint (example: my-endpoint)"
// @param EndpointType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5)
// @param EndpointCreationType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5)
// @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)"
// @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)"
// @param GroupID formData int false "Endpoint group identifier. If not specified will default to 1 (unassigned)."
@@ -322,8 +322,8 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Extensions: []portainer.EndpointExtension{},
TagIDs: payload.TagIDs,
Status: portainer.EndpointStatusUp,
@@ -471,6 +471,7 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.
AllowVolumeBrowserForRegularUsers: false,
EnableHostManagementFeatures: false,
AllowSysctlSettingForRegularUsers: true,
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,

View File

@@ -103,6 +103,22 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for idx := range registries {
registry := &registries[idx]
if _, ok := registry.RegistryAccesses[endpoint.ID]; ok {
delete(registry.RegistryAccesses, endpoint.ID)
err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update registry accesses", Err: err}
}
}
}
return response.Empty(w)
}

View File

@@ -22,7 +22,7 @@ type dockerhubStatusResponse struct {
Limit int `json:"limit"`
}
// GET request on /api/endpoints/{id}/dockerhub/status
// GET request on /api/endpoints/{id}/dockerhub/{registryId}
func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
@@ -40,13 +40,30 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")}
}
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
var registry *portainer.Registry
if registryID == 0 {
registry = &portainer.Registry{}
} else {
registry, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
if registry.Type != portainer.DockerHubRegistry {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry type", errors.New("Invalid registry type")}
}
}
httpClient := client.NewHTTPClient()
token, err := getDockerHubToken(httpClient, dockerhub)
token, err := getDockerHubToken(httpClient, registry)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub token from DockerHub", err}
}
@@ -59,7 +76,7 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
return response.JSON(w, resp)
}
func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.DockerHub) (string, error) {
func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Registry) (string, error) {
type dockerhubTokenResponse struct {
Token string `json:"token"`
}
@@ -71,8 +88,8 @@ func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.Docke
return "", err
}
if dockerhub.Authentication {
req.SetBasicAuth(dockerhub.Username, dockerhub.Password)
if registry.Authentication {
req.SetBasicAuth(registry.Username, registry.Password)
}
resp, err := httpClient.Do(req)

View File

@@ -0,0 +1,50 @@
package endpoints
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
// GET request on /endpoints/{id}/registries/{registryId}
func (handler *Handler) endpointRegistryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a registry with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a registry with the specified identifier inside the database", Err: err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user from the database", Err: err}
}
if !security.AuthorizedRegistryAccess(registry, user, securityContext.UserMemberships, portainer.EndpointID(endpointID)) {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
hideRegistryFields(registry, !securityContext.IsAdmin)
return response.JSON(w, registry)
}

View File

@@ -0,0 +1,130 @@
package endpoints
import (
"net/http"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/security"
endpointutils "github.com/portainer/portainer/api/internal/endpoint"
)
// GET request on /endpoints/{id}/registries?namespace
func (handler *Handler) endpointRegistriesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user from the database", err}
}
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
isAdmin := securityContext.IsAdmin
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
if endpointutils.IsKubernetesEndpoint(endpoint) {
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
if namespace == "" && !isAdmin {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Missing namespace query parameter", Err: errors.New("missing namespace query parameter")}
}
if namespace != "" {
authorized, err := handler.isNamespaceAuthorized(endpoint, namespace, user.ID, securityContext.UserMemberships, isAdmin)
if err != nil {
return &httperror.HandlerError{http.StatusNotFound, "Unable to check for namespace authorization", err}
}
if !authorized {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized to use namespace", Err: errors.New("user is not authorized to use namespace")}
}
registries = filterRegistriesByNamespace(registries, endpoint.ID, namespace)
}
} else if !isAdmin {
registries = security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
}
for idx := range registries {
hideRegistryFields(&registries[idx], !isAdmin)
}
return response.JSON(w, registries)
}
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
if isAdmin || namespace == "" {
return true, nil
}
if namespace == "default" {
return true, nil
}
kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return false, errors.Wrap(err, "unable to retrieve kubernetes client")
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, errors.Wrap(err, "unable to retrieve endpoint's namespaces policies")
}
namespacePolicy, ok := accessPolicies[namespace]
if !ok {
return false, nil
}
return security.AuthorizedAccess(userId, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies), nil
}
func filterRegistriesByNamespace(registries []portainer.Registry, endpointId portainer.EndpointID, namespace string) []portainer.Registry {
if namespace == "" {
return registries
}
filteredRegistries := []portainer.Registry{}
for _, registry := range registries {
for _, authorizedNamespace := range registry.RegistryAccesses[endpointId].Namespaces {
if authorizedNamespace == namespace {
filteredRegistries = append(filteredRegistries, registry)
}
}
}
return filteredRegistries
}
func hideRegistryFields(registry *portainer.Registry, hideAccesses bool) {
registry.Password = ""
registry.ManagementConfiguration = nil
if hideAccesses {
registry.RegistryAccesses = nil
}
}

View File

@@ -0,0 +1,149 @@
package endpoints
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/security"
)
type registryAccessPayload struct {
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
Namespaces []string
}
func (payload *registryAccessPayload) Validate(r *http.Request) error {
return nil
}
// PUT request on /endpoints/{id}/registries/{registryId}
func (handler *Handler) endpointRegistryAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized", Err: err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
var payload registryAccessPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
if registry.RegistryAccesses == nil {
registry.RegistryAccesses = portainer.RegistryAccesses{}
}
if _, ok := registry.RegistryAccesses[endpoint.ID]; !ok {
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{}
}
registryAccess := registry.RegistryAccesses[endpoint.ID]
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err := handler.updateKubeAccess(endpoint, registry, registryAccess.Namespaces, payload.Namespaces)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update kube access policies", Err: err}
}
registryAccess.Namespaces = payload.Namespaces
} else {
registryAccess.UserAccessPolicies = payload.UserAccessPolicies
registryAccess.TeamAccessPolicies = payload.TeamAccessPolicies
}
registry.RegistryAccesses[portainer.EndpointID(endpointID)] = registryAccess
handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
return response.Empty(w)
}
func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
oldNamespacesSet := toSet(oldNamespaces)
newNamespacesSet := toSet(newNamespaces)
namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet)
namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet)
cli, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return err
}
for namespace := range namespacesToRemove {
err := cli.DeleteRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
for namespace := range namespacesToAdd {
err := cli.CreateRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
return nil
}
type stringSet map[string]bool
func toSet(list []string) stringSet {
set := stringSet{}
for _, el := range list {
set[el] = true
}
return set
}
// setDifference returns the set difference tagsA - tagsB
func setDifference(setA stringSet, setB stringSet) stringSet {
set := stringSet{}
for el := range setA {
if !setB[el] {
set[el] = true
}
}
return set
}

View File

@@ -155,11 +155,14 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
endpoint.Kubernetes = *payload.Kubernetes
}
updateAuthorizations := false
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) {
updateAuthorizations = true
endpoint.UserAccessPolicies = payload.UserAccessPolicies
}
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) {
updateAuthorizations = true
endpoint.TeamAccessPolicies = payload.TeamAccessPolicies
}
@@ -252,6 +255,15 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
}
if updateAuthorizations {
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(endpoint, nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err}
}
}
}
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}

View File

@@ -5,6 +5,8 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/kubernetes/cli"
"net/http"
@@ -27,7 +29,9 @@ type Handler struct {
ProxyManager *proxy.Manager
ReverseTunnelService portainer.ReverseTunnelService
SnapshotService portainer.SnapshotService
K8sClientFactory *cli.ClientFactory
ComposeStackManager portainer.ComposeStackManager
AuthorizationService *authorization.Service
}
// NewHandler creates a handler to manage endpoint operations.
@@ -51,7 +55,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/dockerhub",
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/extensions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
@@ -61,5 +65,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/registries",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistriesList))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/registries/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryInspect))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/registries/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut)
return h
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
"github.com/portainer/portainer/api/http/handler/customtemplates"
"github.com/portainer/portainer/api/http/handler/dockerhub"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
@@ -39,7 +38,6 @@ type Handler struct {
AuthHandler *auth.Handler
BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler
DockerHubHandler *dockerhub.Handler
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler
@@ -88,8 +86,6 @@ type Handler struct {
// @tag.description Authenticate against Portainer HTTP API
// @tag.name custom_templates
// @tag.description Manage Custom Templates
// @tag.name dockerhub
// @tag.description Manage how Portainer connects to the DockerHub
// @tag.name edge_groups
// @tag.description Manage Edge Groups
// @tag.name edge_jobs
@@ -146,8 +142,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/restore"):
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):

View File

@@ -5,32 +5,46 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
)
func hideFields(registry *portainer.Registry) {
func hideFields(registry *portainer.Registry, hideAccesses bool) {
registry.Password = ""
registry.ManagementConfiguration = nil
if hideAccesses {
registry.RegistryAccesses = nil
}
}
// Handler is the HTTP handler used to handle registry operations.
type Handler struct {
*mux.Router
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
K8sClientFactory *cli.ClientFactory
}
// NewHandler creates a handler to manage registry operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
h := newHandler(bouncer)
h.initRouter(bouncer)
return h
}
func newHandler(bouncer *security.RequestBouncer) *Handler {
return &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
}
func (h *Handler) initRouter(bouncer accessGuard) {
h.Handle("/registries",
bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost)
h.Handle("/registries",
@@ -45,5 +59,21 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
h.PathPrefix("/registries/proxies/gitlab").Handler(
bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry)))
return h
}
type accessGuard interface {
AdminAccess(h http.Handler) http.Handler
RestrictedAccess(h http.Handler) http.Handler
AuthenticatedAccess(h http.Handler) http.Handler
}
func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Registry) bool {
hasSameUrl := r1.URL == r2.URL
hasSameCredentials := r1.Authentication == r2.Authentication && (!r1.Authentication || (r1.Authentication && r1.Username == r2.Username))
if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry {
return hasSameUrl && hasSameCredentials
}
return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
type registryConfigurePayload struct {
@@ -93,9 +95,12 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries/{id}/configure [post]
func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to configure registry", httperrors.ErrResourceAccessDenied}
}
payload := &registryConfigurePayload{}
@@ -104,6 +109,11 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}

View File

@@ -2,6 +2,7 @@ package registries
import (
"errors"
"fmt"
"net/http"
"github.com/asaskevich/govalidator"
@@ -9,15 +10,19 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
type registryCreatePayload struct {
// Name that will be used to identify this registry
Name string `example:"my-registry" validate:"required"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6"`
// URL or IP address of the Docker registry
URL string `example:"registry.mydomain.tld:2375" validate:"required"`
URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"`
// BaseURL required for ProGet registry
BaseURL string `example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
@@ -30,7 +35,7 @@ type registryCreatePayload struct {
Quay portainer.QuayRegistryData
}
func (payload *registryCreatePayload) Validate(r *http.Request) error {
func (payload *registryCreatePayload) Validate(_ *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid registry name")
}
@@ -40,9 +45,17 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
}
if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry {
return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)")
switch payload.Type {
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry:
default:
return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)")
}
if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" {
return fmt.Errorf("BaseURL is required for registry type %d (ProGet)", portainer.ProGetRegistry)
}
return nil
}
@@ -60,23 +73,41 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries [post]
func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create registry", httperrors.ErrResourceAccessDenied}
}
var payload registryCreatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry := &portainer.Registry{
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
Authentication: payload.Authentication,
Username: payload.Username,
Password: payload.Password,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
Gitlab: payload.Gitlab,
Quay: payload.Quay,
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
BaseURL: payload.BaseURL,
Authentication: payload.Authentication,
Username: payload.Username,
Password: payload.Password,
RegistryAccesses: portainer.RegistryAccesses{},
Gitlab: payload.Gitlab,
Quay: payload.Quay,
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for _, r := range registries {
if handler.registriesHaveSameURLAndCredentials(&r, registry) {
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")}
}
}
err = handler.DataStore.Registry().CreateRegistry(registry)
@@ -84,6 +115,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err}
}
hideFields(registry)
hideFields(registry, true)
return response.JSON(w, registry)
}

View File

@@ -0,0 +1,113 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
)
func Test_registryCreatePayload_Validate(t *testing.T) {
basePayload := registryCreatePayload{Name: "Test registry", URL: "http://example.com"}
t.Run("Can't create a ProGet registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
err := payload.Validate(nil)
assert.Error(t, err)
})
t.Run("Can create a GitLab registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.GitlabRegistry
err := payload.Validate(nil)
assert.NoError(t, err)
})
t.Run("Can create a ProGet registry if BaseURL is not empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
payload.BaseURL = "http://example.com"
err := payload.Validate(nil)
assert.NoError(t, err)
})
}
type testRegistryService struct {
portainer.RegistryService
createRegistry func(r *portainer.Registry) error
updateRegistry func(ID portainer.RegistryID, r *portainer.Registry) error
getRegistry func(ID portainer.RegistryID) (*portainer.Registry, error)
}
type testDataStore struct {
portainer.DataStore
registry *testRegistryService
}
func (t testDataStore) Registry() portainer.RegistryService {
return t.registry
}
func (t testRegistryService) CreateRegistry(r *portainer.Registry) error {
return t.createRegistry(r)
}
func (t testRegistryService) UpdateRegistry(ID portainer.RegistryID, r *portainer.Registry) error {
return t.updateRegistry(ID, r)
}
func (t testRegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
return t.getRegistry(ID)
}
func (t testRegistryService) Registries() ([]portainer.Registry, error) {
return nil, nil
}
func TestHandler_registryCreate(t *testing.T) {
payload := registryCreatePayload{
Name: "Test registry",
Type: portainer.ProGetRegistry,
URL: "http://example.com",
BaseURL: "http://example.com",
Authentication: false,
Username: "username",
Password: "password",
Gitlab: portainer.GitlabRegistryData{},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: portainer.UserID(1),
}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
registry := portainer.Registry{}
handler := Handler{}
handler.DataStore = testDataStore{
registry: &testRegistryService{
createRegistry: func(r *portainer.Registry) error {
registry = *r
return nil
},
},
}
handlerError := handler.registryCreate(w, r)
assert.Nil(t, handlerError)
assert.Equal(t, payload.Name, registry.Name)
assert.Equal(t, payload.Type, registry.Type)
assert.Equal(t, payload.URL, registry.URL)
assert.Equal(t, payload.BaseURL, registry.BaseURL)
assert.Equal(t, payload.Authentication, registry.Authentication)
assert.Equal(t, payload.Username, registry.Username)
assert.Equal(t, payload.Password, registry.Password)
}

View File

@@ -8,6 +8,8 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
// @id RegistryDelete
@@ -23,6 +25,14 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [delete]
func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete registry", httperrors.ErrResourceAccessDenied}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}

View File

@@ -5,7 +5,6 @@ import (
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -27,6 +26,7 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [get]
func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
@@ -39,11 +39,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
err = handler.requestBouncer.RegistryAccess(r, registry)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", errors.ErrEndpointAccessDenied}
}
hideFields(registry)
hideFields(registry, false)
return response.JSON(w, registry)
}

View File

@@ -5,6 +5,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
@@ -21,21 +22,18 @@ import (
// @failure 500 "Server error"
// @router /registries [get]
func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list registries, use /endpoints/:endpointId/registries route instead", httperrors.ErrResourceAccessDenied}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
filteredRegistries := security.FilterRegistries(registries, securityContext)
for idx := range filteredRegistries {
hideFields(&filteredRegistries[idx])
}
return response.JSON(w, filteredRegistries)
return response.JSON(w, registries)
}

View File

@@ -9,6 +9,8 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
type registryUpdatePayload struct {
@@ -16,15 +18,16 @@ type registryUpdatePayload struct {
Name *string `validate:"required" example:"my-registry"`
// URL or IP address of the Docker registry
URL *string `validate:"required" example:"registry.mydomain.tld:2375"`
// BaseURL is used for quay registry
BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication *bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
Username *string `example:"registry_user"`
// Password used to authenticate against this registry. required when Authentication is true
Password *string `example:"registry_password"`
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
Quay *portainer.QuayRegistryData
Password *string `example:"registry_password"`
RegistryAccesses *portainer.RegistryAccesses
Quay *portainer.QuayRegistryData
}
func (payload *registryUpdatePayload) Validate(r *http.Request) error {
@@ -48,17 +51,19 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries/{id} [put]
func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update registry", httperrors.ErrResourceAccessDenied}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
var payload registryUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
@@ -66,27 +71,26 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
var payload registryUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
if payload.Name != nil {
registry.Name = *payload.Name
}
if payload.URL != nil {
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for _, r := range registries {
if r.ID != registry.ID && hasSameURL(&r, registry) {
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", errors.New("A registry is already defined for this URL")}
}
}
shouldUpdateSecrets := false
registry.URL = *payload.URL
if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil {
registry.BaseURL = *payload.BaseURL
}
if payload.Authentication != nil {
if *payload.Authentication {
registry.Authentication = true
shouldUpdateSecrets = shouldUpdateSecrets || (payload.Username != nil && *payload.Username != registry.Username) || (payload.Password != nil && *payload.Password != registry.Password)
if payload.Username != nil {
registry.Username = *payload.Username
@@ -103,12 +107,35 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
}
}
if payload.UserAccessPolicies != nil {
registry.UserAccessPolicies = payload.UserAccessPolicies
if payload.URL != nil {
shouldUpdateSecrets = shouldUpdateSecrets || (*payload.URL != registry.URL)
registry.URL = *payload.URL
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
for _, r := range registries {
if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) {
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")}
}
}
}
if payload.TeamAccessPolicies != nil {
registry.TeamAccessPolicies = payload.TeamAccessPolicies
if shouldUpdateSecrets {
for endpointID, endpointAccess := range registry.RegistryAccesses {
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err}
}
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err}
}
}
}
}
if payload.Quay != nil {
@@ -123,10 +150,24 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return response.JSON(w, registry)
}
func hasSameURL(r1, r2 *portainer.Registry) bool {
if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry {
return r1.URL == r2.URL
func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error {
cli, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return err
}
return r1.URL == r2.URL && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
for _, namespace := range endpointAccess.Namespaces {
err := cli.DeleteRegistrySecret(registry, namespace)
if err != nil {
return err
}
err = cli.CreateRegistrySecret(registry, namespace)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,88 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/stretchr/testify/assert"
)
func ps(s string) *string {
return &s
}
func pb(b bool) *bool {
return &b
}
type TestBouncer struct{}
func (t TestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) RestrictedAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
return h
}
func TestHandler_registryUpdate(t *testing.T) {
payload := registryUpdatePayload{
Name: ps("Updated test registry"),
URL: ps("http://example.org/feed"),
BaseURL: ps("http://example.org"),
Authentication: pb(true),
Username: ps("username"),
Password: ps("password"),
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
registry := portainer.Registry{Type: portainer.ProGetRegistry, ID: 5}
r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: portainer.UserID(1),
}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
updatedRegistry := portainer.Registry{}
handler := newHandler(nil)
handler.initRouter(TestBouncer{})
handler.DataStore = testDataStore{
registry: &testRegistryService{
getRegistry: func(_ portainer.RegistryID) (*portainer.Registry, error) {
return &registry, nil
},
updateRegistry: func(ID portainer.RegistryID, r *portainer.Registry) error {
assert.Equal(t, ID, r.ID)
updatedRegistry = *r
return nil
},
},
}
handler.Router.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
// Registry type should remain intact
assert.Equal(t, registry.Type, updatedRegistry.Type)
assert.Equal(t, *payload.Name, updatedRegistry.Name)
assert.Equal(t, *payload.URL, updatedRegistry.URL)
assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL)
assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication)
assert.Equal(t, *payload.Username, updatedRegistry.Username)
assert.Equal(t, *payload.Password, updatedRegistry.Password)
}

View File

@@ -18,6 +18,8 @@ type publicSettingsResponse struct {
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
// The URL used for oauth login
OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"`
// The URL used for oauth logout
OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"true"`
}
@@ -34,20 +36,32 @@ type publicSettingsResponse struct {
func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
}
publicSettings := &publicSettingsResponse{
LogoURL: settings.LogoURL,
AuthenticationMethod: settings.AuthenticationMethod,
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
EnableTelemetry: settings.EnableTelemetry,
OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login",
settings.OAuthSettings.AuthorizationURI,
settings.OAuthSettings.ClientID,
settings.OAuthSettings.RedirectURI,
settings.OAuthSettings.Scopes),
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve the settings from the database", Err: err}
}
publicSettings := generatePublicSettings(settings)
return response.JSON(w, publicSettings)
}
func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResponse {
publicSettings := &publicSettingsResponse{
LogoURL: appSettings.LogoURL,
AuthenticationMethod: appSettings.AuthenticationMethod,
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
EnableTelemetry: appSettings.EnableTelemetry,
}
//if OAuth authentication is on, compose the related fields from application settings
if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth {
publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI
publicSettings.OAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
appSettings.OAuthSettings.AuthorizationURI,
appSettings.OAuthSettings.ClientID,
appSettings.OAuthSettings.RedirectURI,
appSettings.OAuthSettings.Scopes)
//control prompt=login param according to the SSO setting
if !appSettings.OAuthSettings.SSO {
publicSettings.OAuthLoginURI += "&prompt=login"
}
}
return publicSettings
}

View File

@@ -0,0 +1,70 @@
package settings
import (
"fmt"
"testing"
portainer "github.com/portainer/portainer/api"
)
const (
dummyOAuthClientID = "1a2b3c4d"
dummyOAuthScopes = "scopes"
dummyOAuthAuthenticationURI = "example.com/auth"
dummyOAuthRedirectURI = "example.com/redirect"
dummyOAuthLogoutURI = "example.com/logout"
)
var (
dummyOAuthLoginURI string
mockAppSettings *portainer.Settings
)
func setup() {
dummyOAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
dummyOAuthAuthenticationURI,
dummyOAuthClientID,
dummyOAuthRedirectURI,
dummyOAuthScopes)
mockAppSettings = &portainer.Settings{
AuthenticationMethod: portainer.AuthenticationOAuth,
OAuthSettings: portainer.OAuthSettings{
AuthorizationURI: dummyOAuthAuthenticationURI,
ClientID: dummyOAuthClientID,
Scopes: dummyOAuthScopes,
RedirectURI: dummyOAuthRedirectURI,
LogoutURI: dummyOAuthLogoutURI,
},
}
}
func TestGeneratePublicSettingsWithSSO(t *testing.T) {
setup()
mockAppSettings.OAuthSettings.SSO = true
publicSettings := generatePublicSettings(mockAppSettings)
if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth {
t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod)
}
if publicSettings.OAuthLoginURI != dummyOAuthLoginURI {
t.Errorf("wrong OAuthLoginURI when SSO is switched on, want: %s, got: %s", dummyOAuthLoginURI, publicSettings.OAuthLoginURI)
}
if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI {
t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI)
}
}
func TestGeneratePublicSettingsWithoutSSO(t *testing.T) {
setup()
mockAppSettings.OAuthSettings.SSO = false
publicSettings := generatePublicSettings(mockAppSettings)
if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth {
t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod)
}
expectedOAuthLoginURI := dummyOAuthLoginURI + "&prompt=login"
if publicSettings.OAuthLoginURI != expectedOAuthLoginURI {
t.Errorf("wrong OAuthLoginURI when SSO is switched off, want: %s, got: %s", expectedOAuthLoginURI, publicSettings.OAuthLoginURI)
}
if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI {
t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI)
}
}

View File

@@ -169,19 +169,10 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
referenceName: payload.RepositoryReferenceName,
path: projectPath,
authentication: payload.RepositoryAuthentication,
username: payload.RepositoryUsername,
password: payload.RepositoryPassword,
}
doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp)
err = handler.cloneGitRepository(gitCloneParams)
err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
}
@@ -246,11 +237,11 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
}
if !isUnique {
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: errorMessage, Err: errors.New(errorMessage)}
}
stackID := handler.DataStore.Stack().GetNextIdentifier()
@@ -299,7 +290,6 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
type composeStackDeploymentConfig struct {
stack *portainer.Stack
endpoint *portainer.Endpoint
dockerhub *portainer.DockerHub
registries []portainer.Registry
isAdmin bool
user *portainer.User
@@ -311,26 +301,20 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve DockerHub details from the database", Err: err}
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve registries from the database", Err: err}
}
filteredRegistries := security.FilterRegistries(registries, securityContext)
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
config := &composeStackDeploymentConfig{
stack: stack,
endpoint: endpoint,
dockerhub: dockerhub,
registries: filteredRegistries,
isAdmin: securityContext.IsAdmin,
user: user,
@@ -375,7 +359,7 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock()
handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint)
handler.SwarmStackManager.Login(config.registries, config.endpoint)
err = handler.ComposeStackManager.Up(config.stack, config.endpoint)
if err != nil {

View File

@@ -2,7 +2,11 @@ package stacks
import (
"errors"
"io/ioutil"
"net/http"
"path/filepath"
"strconv"
"time"
"github.com/asaskevich/govalidator"
@@ -10,15 +14,29 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
)
type kubernetesStackPayload struct {
const defaultReferenceName = "refs/heads/master"
type kubernetesStringDeploymentPayload struct {
ComposeFormat bool
Namespace string
StackFileContent string
}
func (payload *kubernetesStackPayload) Validate(r *http.Request) error {
type kubernetesGitDeploymentPayload struct {
ComposeFormat bool
Namespace string
RepositoryURL string
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
FilePathInRepository string
}
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content")
}
@@ -28,32 +46,146 @@ func (payload *kubernetesStackPayload) Validate(r *http.Request) error {
return nil
}
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Namespace) {
return errors.New("Invalid namespace")
}
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if govalidator.IsNull(payload.FilePathInRepository) {
return errors.New("Invalid file path in repository")
}
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultReferenceName
}
return nil
}
type createKubernetesStackResponse struct {
Output string `json:"Output"`
}
func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
var payload kubernetesStackPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
var payload kubernetesStringDeploymentPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ManifestFileDefaultName,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
stackFolder := strconv.Itoa(int(stack.ID))
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
}
stack.ProjectPath = projectPath
doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp)
output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to deploy Kubernetes stack", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
}
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
}
resp := &createKubernetesStackResponse{
Output: string(output),
Output: output,
}
return response.JSON(w, resp)
}
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) {
func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
var payload kubernetesGitDeploymentPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack,
EndpointID: endpoint.ID,
EntryPoint: payload.FilePathInRepository,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp)
stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
}
output, err := handler.deployKubernetesStack(endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
}
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
}
resp := &createKubernetesStackResponse{
Output: output,
}
return response.JSON(w, resp)
}
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) {
handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock()
return handler.KubernetesDeployer.Deploy(endpoint, data, composeFormat, namespace)
if composeFormat {
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(stackConfig)
if err != nil {
return "", err
}
stackConfig = string(convertedConfig)
}
return handler.KubernetesDeployer.Deploy(endpoint, stackConfig, namespace)
}
func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
repositoryUsername := gitInfo.RepositoryUsername
repositoryPassword := gitInfo.RepositoryPassword
if !gitInfo.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err := handler.GitService.CloneRepository(projectPath, gitInfo.RepositoryURL, gitInfo.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return "", err
}
content, err := ioutil.ReadFile(filepath.Join(projectPath, gitInfo.FilePathInRepository))
if err != nil {
return "", err
}
return string(content), nil
}

View File

@@ -0,0 +1,64 @@
package stacks
import (
"io/ioutil"
"os"
"path"
"testing"
"github.com/stretchr/testify/assert"
)
type git struct {
content string
}
func (g *git) CloneRepository(destination string, repositoryURL, referenceName, username, password string) error {
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
}
func (g *git) ClonePublicRepository(repositoryURL string, referenceName string, destination string) error {
return ioutil.WriteFile(path.Join(destination, "deployment.yml"), []byte(g.content), 0755)
}
func (g *git) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
}
func TestCloneAndConvertGitRepoFile(t *testing.T) {
dir, err := os.MkdirTemp("", "kube-create-stack")
assert.NoError(t, err, "failed to create a tmp dir")
defer os.RemoveAll(dir)
content := `apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80`
h := &Handler{
GitService: &git{
content: content,
},
}
gitInfo := &kubernetesGitDeploymentPayload{
FilePathInRepository: "deployment.yml",
}
fileContent, err := h.cloneManifestContentFromGitRepo(gitInfo, dir)
assert.NoError(t, err, "failed to clone or convert the file from Git repo")
assert.Equal(t, content, fileContent)
}

View File

@@ -173,21 +173,12 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
referenceName: payload.RepositoryReferenceName,
path: projectPath,
authentication: payload.RepositoryAuthentication,
username: payload.RepositoryUsername,
password: payload.RepositoryPassword,
}
doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp)
err = handler.cloneGitRepository(gitCloneParams)
err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
}
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
@@ -309,7 +300,6 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
type swarmStackDeploymentConfig struct {
stack *portainer.Stack
endpoint *portainer.Endpoint
dockerhub *portainer.DockerHub
registries []portainer.Registry
prune bool
isAdmin bool
@@ -322,26 +312,20 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
filteredRegistries := security.FilterRegistries(registries, securityContext)
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
config := &swarmStackDeploymentConfig{
stack: stack,
endpoint: endpoint,
dockerhub: dockerhub,
registries: filteredRegistries,
prune: prune,
isAdmin: securityContext.IsAdmin,
@@ -376,7 +360,7 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock()
handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint)
handler.SwarmStackManager.Login(config.registries, config.endpoint)
err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint)
if err != nil {

View File

@@ -1,17 +0,0 @@
package stacks
type cloneRepositoryParameters struct {
url string
referenceName string
path string
authentication bool
username string
password string
}
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
if parameters.authentication {
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
}
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
}

View File

@@ -52,8 +52,12 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete)
h.Handle("/stacks/{id}/associate",
bouncer.AdminAccess(httperror.LoggerHandler(h.stackAssociate))).Methods(http.MethodPut)
h.Handle("/stacks/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut)
h.Handle("/stacks/{id}/git",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPut)
h.Handle("/stacks/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
h.Handle("/stacks/{id}/migrate",

View File

@@ -0,0 +1,91 @@
package stacks
import (
"fmt"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
"net/http"
"time"
)
// PUT request on /api/stacks/:id/associate?endpointId=<endpointId>&swarmId=<swarmId>&orphanedRunning=<orphanedRunning>
func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
swarmId, err := request.RetrieveQueryParameter(r, "swarmId", true)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: swarmId", err}
}
orphanedRunning, err := request.RetrieveBooleanQueryParameter(r, "orphanedRunning", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: orphanedRunning", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
}
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
if resourceControl != nil {
resourceControl.ResourceID = fmt.Sprintf("%d_%s", endpointID, stack.Name)
err = handler.DataStore.ResourceControl().UpdateResourceControl(resourceControl.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err}
}
}
stack.EndpointID = portainer.EndpointID(endpointID)
stack.SwarmID = swarmId
if orphanedRunning {
stack.Status = portainer.StackStatusActive
} else {
stack.Status = portainer.StackStatusInactive
}
stack.CreationDate = time.Now().Unix()
stack.CreatedBy = user.Username
stack.UpdateDate = 0
stack.UpdatedBy = ""
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
}
stack.ResourceControl = resourceControl
return response.JSON(w, stack)
}

View File

@@ -2,6 +2,7 @@ package stacks
import (
"errors"
"fmt"
"log"
"net/http"
@@ -12,9 +13,10 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/stackutils"
)
@@ -76,7 +78,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if !endpoint.SecuritySettings.AllowStackManagementForRegularUsers {
if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
@@ -110,11 +112,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
case portainer.DockerComposeStack:
return handler.createComposeStack(w, r, method, endpoint, tokenData.ID)
case portainer.KubernetesStack:
if tokenData.Role != portainer.AdministratorRole {
return &httperror.HandlerError{http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized}
}
return handler.createKubernetesStack(w, r, endpoint)
return handler.createKubernetesStack(w, r, method, endpoint)
}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)}
@@ -147,6 +145,16 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
}
func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError {
switch method {
case "string":
return handler.createKubernetesStackFromFileContent(w, r, endpoint)
case "repository":
return handler.createKubernetesStackFromGitRepository(w, r, endpoint)
}
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
}
func (handler *Handler) isValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
if err != nil {
@@ -226,3 +234,22 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
stack.ResourceControl = resourceControl
return response.JSON(w, stack)
}
func (handler *Handler) cloneAndSaveConfig(stack *portainer.Stack, projectPath, repositoryURL, refName, configFilePath string, auth bool, username, password string) error {
if !auth {
username = ""
password = ""
}
err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password)
if err != nil {
return fmt.Errorf("unable to clone git repository: %w", err)
}
stack.GitConfig = &gittypes.RepoConfig{
URL: repositoryURL,
ReferenceName: refName,
ConfigFilePath: configFilePath,
}
return nil
}

View File

@@ -0,0 +1,29 @@
package stacks
import (
"testing"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func Test_stackHandler_cloneAndSaveConfig_shouldCallGitCloneAndSaveConfigOnStack(t *testing.T) {
handler := NewHandler(&security.RequestBouncer{})
handler.GitService = testhelpers.NewGitService()
url := "url"
refName := "ref"
configPath := "path"
stack := &portainer.Stack{}
err := handler.cloneAndSaveConfig(stack, "", url, refName, configPath, false, "", "")
assert.NoError(t, err, "clone and save should not fail")
assert.Equal(t, gittypes.RepoConfig{
URL: url,
ReferenceName: refName,
ConfigFilePath: configPath,
}, *stack.GitConfig)
}

View File

@@ -58,42 +58,42 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API endpoint
// can use the optional EndpointID query parameter to set a valid endpoint identifier to be
// used in the context of this request.
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
endpointIdentifier := stack.EndpointID
if endpointID != 0 {
endpointIdentifier = portainer.EndpointID(endpointID)
isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID
if isOrphaned && !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointIdentifier)
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
if !isOrphaned {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
}
err = handler.deleteStack(stack, endpoint)

View File

@@ -46,34 +46,38 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
if endpoint != nil {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
}
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))

View File

@@ -1,6 +1,7 @@
package stacks
import (
"github.com/portainer/portainer/api/http/errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
@@ -8,7 +9,6 @@ import (
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
)
@@ -40,38 +40,42 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
if !securityContext.IsAdmin {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
if endpoint != nil {
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
if resourceControl != nil {
stack.ResourceControl = resourceControl
if resourceControl != nil {
stack.ResourceControl = resourceControl
}
}
return response.JSON(w, stack)

View File

@@ -1,6 +1,7 @@
package stacks
import (
httperrors "github.com/portainer/portainer/api/http/errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
@@ -12,8 +13,9 @@ import (
)
type stackListOperationFilters struct {
SwarmID string `json:"SwarmID"`
EndpointID int `json:"EndpointID"`
SwarmID string `json:"SwarmID"`
EndpointID int `json:"EndpointID"`
IncludeOrphanedStacks bool `json:"IncludeOrphanedStacks"`
}
// @id StackList
@@ -37,11 +39,16 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err}
}
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err}
}
stacks, err := handler.DataStore.Stack().Stacks()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err}
}
stacks = filterStacks(stacks, &filters)
stacks = filterStacks(stacks, &filters, endpoints)
resourceControls, err := handler.DataStore.ResourceControl().ResourceControls()
if err != nil {
@@ -56,6 +63,10 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
stacks = authorization.DecorateStacks(stacks, resourceControls)
if !securityContext.IsAdmin {
if filters.IncludeOrphanedStacks {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access orphaned stacks", httperrors.ErrUnauthorized}
}
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err}
@@ -72,13 +83,20 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
return response.JSON(w, stacks)
}
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack {
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
if filters.EndpointID == 0 && filters.SwarmID == "" {
return stacks
}
filteredStacks := make([]portainer.Stack, 0, len(stacks))
for _, stack := range stacks {
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
filteredStacks = append(filteredStacks, stack)
}
continue
}
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
filteredStacks = append(filteredStacks, stack)
}
@@ -89,3 +107,13 @@ func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters)
return filteredStacks
}
func isOrphanedStack(stack portainer.Stack, endpoints []portainer.Endpoint) bool {
for _, endpoint := range endpoints {
if stack.EndpointID == endpoint.ID {
return false
}
}
return true
}

View File

@@ -191,6 +191,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
stack.Status = portainer.StackStatusActive
err = handler.deploySwarmStack(config)
if err != nil {

View File

@@ -0,0 +1,180 @@
package stacks
import (
"errors"
"fmt"
"log"
"net/http"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/filesystem"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
)
type updateStackGitPayload struct {
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
}
func (payload *updateStackGitPayload) Validate(r *http.Request) error {
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
}
return nil
}
// PUT request on /api/stacks/:id/git?endpointId=<endpointId>
func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
}
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
}
if stack.GitConfig == nil {
return &httperror.HandlerError{http.StatusBadRequest, "Stack is not created from git", err}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API endpoint
// can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack.
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
if endpointID != int(stack.EndpointID) {
stack.EndpointID = portainer.EndpointID(endpointID)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
var payload updateStackGitPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to move git repository directory", err}
}
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
}
err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath)
if restoreError != nil {
log.Printf("[WARN] [http,stacks,git] [error: %s] [message: failed restoring backup folder]", restoreError)
}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
}
defer func() {
err = handler.FileService.RemoveDirectory(backupProjectPath)
if err != nil {
log.Printf("[WARN] [http,stacks,git] [error: %s] [message: unable to remove git repository directory]", err)
}
}()
httpErr := handler.deployStack(r, stack, endpoint)
if httpErr != nil {
return httpErr
}
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
}
return response.JSON(w, stack)
}
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
if stack.Type == portainer.DockerSwarmStack {
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
if httpErr != nil {
return httpErr
}
err := handler.deploySwarmStack(config)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
stack.Status = portainer.StackStatusActive
return nil
}
config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint)
if httpErr != nil {
return httpErr
}
err := handler.deployComposeStack(config)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
stack.Status = portainer.StackStatusActive
return nil
}

View File

@@ -3,11 +3,12 @@ package teams
import (
"net/http"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
)
// @id TeamDelete
@@ -29,7 +30,7 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http
}
_, err = handler.DataStore.Team().Team(portainer.TeamID(teamID))
if err == errors.ErrObjectNotFound {
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err}
@@ -45,5 +46,27 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err}
}
// update default team if deleted team was default
err = handler.updateDefaultTeamIfDeleted(portainer.TeamID(teamID))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to reset default team", err}
}
return response.Empty(w)
}
// updateDefaultTeamIfDeleted resets the default team to nil if default team was the deleted team
func (handler *Handler) updateDefaultTeamIfDeleted(teamID portainer.TeamID) error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return errors.Wrap(err, "failed to fetch settings")
}
if teamID != settings.OAuthSettings.DefaultTeamID {
return nil
}
settings.OAuthSettings.DefaultTeamID = 0
err = handler.DataStore.Settings().UpdateSettings(settings)
return errors.Wrap(err, "failed to update settings")
}

View File

@@ -1,17 +0,0 @@
package templates
type cloneRepositoryParameters struct {
url string
referenceName string
path string
authentication bool
username string
password string
}
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
if parameters.authentication {
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
}
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
}

View File

@@ -63,12 +63,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
defer handler.cleanUp(projectPath)
gitCloneParams := &cloneRepositoryParameters{
url: payload.RepositoryURL,
path: projectPath,
}
err = handler.cloneGitRepository(gitCloneParams)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
}

View File

@@ -5,7 +5,7 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*
@@ -49,7 +49,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
errObj := map[string]string{
"message": "A container instance with the same name already exists inside the selected resource group",
}
err = responseutils.RewriteResponse(resp, errObj, http.StatusConflict)
err = utils.RewriteResponse(resp, errObj, http.StatusConflict)
return resp, err
}
@@ -58,7 +58,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
return response, err
}
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return response, err
}
@@ -80,7 +80,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
responseObject = decorateObject(responseObject, resourceControl)
err = responseutils.RewriteResponse(response, responseObject, http.StatusOK)
err = utils.RewriteResponse(response, responseObject, http.StatusOK)
if err != nil {
return response, err
}
@@ -94,7 +94,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request)
return response, err
}
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
@@ -106,7 +106,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request)
responseObject = transport.decorateContainerGroup(responseObject, context)
responseutils.RewriteResponse(response, responseObject, http.StatusOK)
utils.RewriteResponse(response, responseObject, http.StatusOK)
return response, nil
}
@@ -118,7 +118,7 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque
}
if !transport.userCanDeleteContainerGroup(request, context) {
return responseutils.WriteAccessDeniedResponse()
return utils.WriteAccessDeniedResponse()
}
response, err := http.DefaultTransport.RoundTrip(request)
@@ -126,14 +126,14 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque
return response, err
}
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
transport.removeResourceControl(responseObject, context)
responseutils.RewriteResponse(response, responseObject, http.StatusOK)
utils.RewriteResponse(response, responseObject, http.StatusOK)
return response, nil
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"net/http"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
// proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups
@@ -23,7 +23,7 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request
return nil, err
}
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
@@ -39,10 +39,10 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request
filteredValue := transport.filterContainerGroups(decoratedValue, context)
responseObject["value"] = filteredValue
responseutils.RewriteResponse(response, responseObject, http.StatusOK)
utils.RewriteResponse(response, responseObject, http.StatusOK)
} else {
return nil, fmt.Errorf("The container groups response has no value property")
}
return response, nil
}
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/portainer/portainer/api/internal/stackutils"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
portainer "github.com/portainer/portainer/api"
@@ -162,7 +162,7 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe
systemResourceControl := findSystemNetworkResourceControl(responseObject)
if systemResourceControl != nil {
responseObject = decorateObject(responseObject, systemResourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
}
@@ -175,15 +175,15 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe
}
if resourceControl == nil && (executor.operationContext.isAdmin) {
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) {
responseObject = decorateObject(responseObject, resourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
return responseutils.RewriteAccessDeniedResponse(response)
return utils.RewriteAccessDeniedResponse(response)
}
func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) {

View File

@@ -7,7 +7,7 @@ import (
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -34,7 +34,7 @@ func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, en
func (transport *Transport) configListOperation(response *http.Response, executor *operationExecutor) error {
// ConfigList response is a JSON array
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
responseArray, err := utils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
@@ -50,7 +50,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
return utils.RewriteResponse(response, responseArray, http.StatusOK)
}
// configInspectOperation extracts the response as a JSON object, verify that the user
@@ -58,7 +58,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo
func (transport *Transport) configInspectOperation(response *http.Response, executor *operationExecutor) error {
// ConfigInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -78,9 +78,9 @@ func (transport *Transport) configInspectOperation(response *http.Response, exec
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigList
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigInspect
func selectorConfigLabels(responseObject map[string]interface{}) map[string]interface{} {
secretSpec := responseutils.GetJSONObject(responseObject, "Spec")
secretSpec := utils.GetJSONObject(responseObject, "Spec")
if secretSpec != nil {
secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels")
secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels")
return secretLabelsObject
}
return nil

View File

@@ -10,7 +10,7 @@ import (
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -46,7 +46,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client,
func (transport *Transport) containerListOperation(response *http.Response, executor *operationExecutor) error {
// ContainerList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
responseArray, err := utils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
@@ -69,7 +69,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
}
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
return utils.RewriteResponse(response, responseArray, http.StatusOK)
}
// containerInspectOperation extracts the response as a JSON object, verify that the user
@@ -77,7 +77,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error {
//ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -96,9 +96,9 @@ func (transport *Transport) containerInspectOperation(response *http.Response, e
// Labels are available under the "Config.Labels" property.
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
func selectorContainerLabelsFromContainerInspectOperation(responseObject map[string]interface{}) map[string]interface{} {
containerConfigObject := responseutils.GetJSONObject(responseObject, "Config")
containerConfigObject := utils.GetJSONObject(responseObject, "Config")
if containerConfigObject != nil {
containerLabelsObject := responseutils.GetJSONObject(containerConfigObject, "Labels")
containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels")
return containerLabelsObject
}
return nil
@@ -109,7 +109,7 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str
// Labels are available under the "Labels" property.
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func selectorContainerLabelsFromContainerListOperation(responseObject map[string]interface{}) map[string]interface{} {
containerLabelsObject := responseutils.GetJSONObject(responseObject, "Labels")
containerLabelsObject := utils.GetJSONObject(responseObject, "Labels")
return containerLabelsObject
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/docker/docker/client"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -38,7 +38,7 @@ func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, e
func (transport *Transport) networkListOperation(response *http.Response, executor *operationExecutor) error {
// NetworkList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
responseArray, err := utils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
@@ -54,7 +54,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
return utils.RewriteResponse(response, responseArray, http.StatusOK)
}
// networkInspectOperation extracts the response as a JSON object, verify that the user
@@ -62,7 +62,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut
func (transport *Transport) networkInspectOperation(response *http.Response, executor *operationExecutor) error {
// NetworkInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -99,5 +99,5 @@ func findSystemNetworkResourceControl(networkObject map[string]interface{}) *por
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func selectorNetworkLabels(responseObject map[string]interface{}) map[string]interface{} {
return responseutils.GetJSONObject(responseObject, "Labels")
return utils.GetJSONObject(responseObject, "Labels")
}

View File

@@ -1,39 +1,43 @@
package docker
import (
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
)
type (
registryAccessContext struct {
isAdmin bool
userID portainer.UserID
user *portainer.User
endpointID portainer.EndpointID
teamMemberships []portainer.TeamMembership
registries []portainer.Registry
dockerHub *portainer.DockerHub
}
registryAuthenticationHeader struct {
Username string `json:"username"`
Password string `json:"password"`
Serveraddress string `json:"serveraddress"`
}
portainerRegistryAuthenticationHeader struct {
RegistryId portainer.RegistryID `json:"registryId"`
}
)
func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader {
func createRegistryAuthenticationHeader(registryId portainer.RegistryID, accessContext *registryAccessContext) *registryAuthenticationHeader {
var authenticationHeader *registryAuthenticationHeader
if serverAddress == "" {
if registryId == 0 { // dockerhub (anonymous)
authenticationHeader = &registryAuthenticationHeader{
Username: accessContext.dockerHub.Username,
Password: accessContext.dockerHub.Password,
Serveraddress: "docker.io",
}
} else {
} else { // any "custom" registry
var matchingRegistry *portainer.Registry
for _, registry := range accessContext.registries {
if registry.URL == serverAddress &&
(accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(&registry, accessContext.userID, accessContext.teamMemberships))) {
if registry.ID == registryId &&
(accessContext.isAdmin ||
security.AuthorizedRegistryAccess(&registry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) {
matchingRegistry = &registry
break
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -34,7 +34,7 @@ func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, en
func (transport *Transport) secretListOperation(response *http.Response, executor *operationExecutor) error {
// SecretList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/SecretList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
responseArray, err := utils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
@@ -50,7 +50,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
return utils.RewriteResponse(response, responseArray, http.StatusOK)
}
// secretInspectOperation extracts the response as a JSON object, verify that the user
@@ -58,7 +58,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo
func (transport *Transport) secretInspectOperation(response *http.Response, executor *operationExecutor) error {
// SecretInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -78,9 +78,9 @@ func (transport *Transport) secretInspectOperation(response *http.Response, exec
// https://docs.docker.com/engine/api/v1.37/#operation/SecretList
// https://docs.docker.com/engine/api/v1.37/#operation/SecretInspect
func selectorSecretLabels(responseObject map[string]interface{}) map[string]interface{} {
secretSpec := responseutils.GetJSONObject(responseObject, "Spec")
secretSpec := utils.GetJSONObject(responseObject, "Spec")
if secretSpec != nil {
secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels")
secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels")
return secretLabelsObject
}
return nil

View File

@@ -12,7 +12,7 @@ import (
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -39,7 +39,7 @@ func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, e
func (transport *Transport) serviceListOperation(response *http.Response, executor *operationExecutor) error {
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
responseArray, err := utils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
@@ -55,7 +55,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
return utils.RewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
@@ -63,7 +63,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut
func (transport *Transport) serviceInspectOperation(response *http.Response, executor *operationExecutor) error {
//ServiceInspect response is a JSON object
//https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -83,9 +83,9 @@ func (transport *Transport) serviceInspectOperation(response *http.Response, exe
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func selectorServiceLabels(responseObject map[string]interface{}) map[string]interface{} {
serviceSpecObject := responseutils.GetJSONObject(responseObject, "Spec")
serviceSpecObject := utils.GetJSONObject(responseObject, "Spec")
if serviceSpecObject != nil {
return responseutils.GetJSONObject(serviceSpecObject, "Labels")
return utils.GetJSONObject(serviceSpecObject, "Labels")
}
return nil
}

View File

@@ -3,7 +3,7 @@ package docker
import (
"net/http"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
// swarmInspectOperation extracts the response as a JSON object and rewrites the response based
@@ -11,7 +11,7 @@ import (
func swarmInspectOperation(response *http.Response, executor *operationExecutor) error {
// SwarmInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -21,5 +21,5 @@ func swarmInspectOperation(response *http.Response, executor *operationExecutor)
delete(responseObject, "TLSInfo")
}
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
)
const (
@@ -16,7 +16,7 @@ const (
func (transport *Transport) taskListOperation(response *http.Response, executor *operationExecutor) error {
// TaskList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/TaskList
responseArray, err := responseutils.GetResponseAsJSONArray(response)
responseArray, err := utils.GetResponseAsJSONArray(response)
if err != nil {
return err
}
@@ -32,18 +32,18 @@ func (transport *Transport) taskListOperation(response *http.Response, executor
return err
}
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
return utils.RewriteResponse(response, responseArray, http.StatusOK)
}
// selectorServiceLabels retrieve the labels object associated to the task object.
// Labels are available under the "Spec.ContainerSpec.Labels" property.
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func selectorTaskLabels(responseObject map[string]interface{}) map[string]interface{} {
taskSpecObject := responseutils.GetJSONObject(responseObject, "Spec")
taskSpecObject := utils.GetJSONObject(responseObject, "Spec")
if taskSpecObject != nil {
containerSpecObject := responseutils.GetJSONObject(taskSpecObject, "ContainerSpec")
containerSpecObject := utils.GetJSONObject(taskSpecObject, "ContainerSpec")
if containerSpecObject != nil {
return responseutils.GetJSONObject(containerSpecObject, "Labels")
return utils.GetJSONObject(containerSpecObject, "Labels")
}
}
return nil

View File

@@ -5,17 +5,19 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -157,21 +159,43 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response,
return transport.administratorOperation(r)
}
volumeName := volumeIDParameter[0]
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeIDParameter[0])
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeName)
if err != nil {
return nil, err
}
// volume browser request
return transport.restrictedResourceOperation(r, resourceID, portainer.VolumeResourceControl, true)
return transport.restrictedResourceOperation(r, resourceID, volumeName, portainer.VolumeResourceControl, true)
case strings.HasPrefix(requestPath, "/dockerhub"):
dockerhub, err := transport.dataStore.DockerHub().DockerHub()
requestPath, registryIdString := path.Split(r.URL.Path)
registryID, err := strconv.Atoi(registryIdString)
if err != nil {
return nil, err
return nil, fmt.Errorf("missing registry id: %w", err)
}
newBody, err := json.Marshal(dockerhub)
r.URL.Path = strings.TrimSuffix(requestPath, "/")
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
}
if registryID != 0 {
registry, err = transport.dataStore.Registry().Registry(portainer.RegistryID(registryID))
if err != nil {
return nil, fmt.Errorf("failed fetching registry: %w", err)
}
}
if registry.Type != portainer.DockerHubRegistry {
return nil, errors.New("Invalid registry type")
}
newBody, err := json.Marshal(registry)
if err != nil {
return nil, err
}
@@ -200,10 +224,10 @@ func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Res
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.configInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, configID, portainer.ConfigResourceControl)
return transport.executeGenericResourceDeletionOperation(request, configID, configID, portainer.ConfigResourceControl)
}
return transport.restrictedResourceOperation(request, configID, portainer.ConfigResourceControl, false)
return transport.restrictedResourceOperation(request, configID, configID, portainer.ConfigResourceControl, false)
}
}
@@ -228,16 +252,16 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.
if action == "json" {
return transport.rewriteOperation(request, transport.containerInspectOperation)
}
return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false)
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
} else if match, _ := path.Match("/containers/*", requestPath); match {
// Handle /containers/{id} requests
containerID := path.Base(requestPath)
if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, containerID, portainer.ContainerResourceControl)
return transport.executeGenericResourceDeletionOperation(request, containerID, containerID, portainer.ContainerResourceControl)
}
return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false)
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
}
return transport.executeDockerRequest(request)
}
@@ -256,7 +280,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
if match, _ := path.Match("/services/*/*", requestPath); match {
// Handle /services/{id}/{action} requests
serviceID := path.Base(path.Dir(requestPath))
return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false)
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
} else if match, _ := path.Match("/services/*", requestPath); match {
// Handle /services/{id} requests
serviceID := path.Base(requestPath)
@@ -265,9 +289,9 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
case http.MethodGet:
return transport.rewriteOperation(request, transport.serviceInspectOperation)
case http.MethodDelete:
return transport.executeGenericResourceDeletionOperation(request, serviceID, portainer.ServiceResourceControl)
return transport.executeGenericResourceDeletionOperation(request, serviceID, serviceID, portainer.ServiceResourceControl)
}
return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false)
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
}
return transport.executeDockerRequest(request)
}
@@ -305,9 +329,9 @@ func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Re
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.networkInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, networkID, portainer.NetworkResourceControl)
return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl)
}
return transport.restrictedResourceOperation(request, networkID, portainer.NetworkResourceControl, false)
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
}
}
@@ -326,9 +350,9 @@ func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Res
if request.Method == http.MethodGet {
return transport.rewriteOperation(request, transport.secretInspectOperation)
} else if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, secretID, portainer.SecretResourceControl)
return transport.executeGenericResourceDeletionOperation(request, secretID, secretID, portainer.SecretResourceControl)
}
return transport.restrictedResourceOperation(request, secretID, portainer.SecretResourceControl, false)
return transport.restrictedResourceOperation(request, secretID, secretID, portainer.SecretResourceControl, false)
}
}
@@ -394,13 +418,13 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re
return nil, err
}
var originalHeaderData registryAuthenticationHeader
var originalHeaderData portainerRegistryAuthenticationHeader
err = json.Unmarshal(decodedHeaderData, &originalHeaderData)
if err != nil {
return nil, err
}
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext)
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.RegistryId, accessContext)
headerData, err := json.Marshal(authenticationHeader)
if err != nil {
@@ -415,7 +439,7 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re
return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl)
}
func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) {
func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, dockerResourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
@@ -430,7 +454,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r
}
if !securitySettings.AllowVolumeBrowserForRegularUsers {
return responseutils.WriteAccessDeniedResponse()
return utils.WriteAccessDeniedResponse()
}
}
@@ -453,20 +477,24 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r
if resourceControl == nil {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
if dockerResourceID == "" {
dockerResourceID = resourceID
}
// This resource was created outside of portainer,
// is part of a Docker service or part of a Docker Swarm/Compose stack.
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(resourceID, agentTargetHeader, resourceType, resourceControls)
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(dockerResourceID, agentTargetHeader, resourceType, resourceControls)
if err != nil {
return nil, err
}
if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) {
return responseutils.WriteAccessDeniedResponse()
return utils.WriteAccessDeniedResponse()
}
}
if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
return responseutils.WriteAccessDeniedResponse()
return utils.WriteAccessDeniedResponse()
}
}
@@ -530,7 +558,7 @@ func (transport *Transport) interceptAndRewriteRequest(request *http.Request, op
// https://docs.docker.com/engine/api/v1.37/#operation/SecretCreate
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigCreate
func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -549,7 +577,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
responseObject = decorateObject(responseObject, resourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
@@ -570,8 +598,8 @@ func (transport *Transport) decorateGenericResourceCreationOperation(request *ht
return response, err
}
func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, resourceType, false)
func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, volumeName string, resourceType portainer.ResourceControlType) (*http.Response, error) {
response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, volumeName, resourceType, false)
if err != nil {
return response, err
}
@@ -612,7 +640,7 @@ func (transport *Transport) administratorOperation(request *http.Request) (*http
}
if tokenData.Role != portainer.AdministratorRole {
return responseutils.WriteAccessDeniedResponse()
return utils.WriteAccessDeniedResponse()
}
return transport.executeDockerRequest(request)
@@ -625,15 +653,15 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) (
}
accessContext := &registryAccessContext{
isAdmin: true,
userID: tokenData.ID,
isAdmin: true,
endpointID: transport.endpoint.ID,
}
hub, err := transport.dataStore.DockerHub().DockerHub()
user, err := transport.dataStore.User().User(tokenData.ID)
if err != nil {
return nil, err
}
accessContext.dockerHub = hub
accessContext.user = user
registries, err := transport.dataStore.Registry().Registries()
if err != nil {
@@ -641,7 +669,7 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) (
}
accessContext.registries = registries
if tokenData.Role != portainer.AdministratorRole {
if user.Role != portainer.AdministratorRole {
accessContext.isAdmin = false
teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)

View File

@@ -9,7 +9,7 @@ import (
"github.com/docker/docker/client"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -37,7 +37,7 @@ func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, en
func (transport *Transport) volumeListOperation(response *http.Response, executor *operationExecutor) error {
// VolumeList response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -68,7 +68,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
responseObject["Volumes"] = volumeData
}
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
// volumeInspectOperation extracts the response as a JSON object, verify that the user
@@ -76,7 +76,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
func (transport *Transport) volumeInspectOperation(response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -101,7 +101,7 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func selectorVolumeLabels(responseObject map[string]interface{}) map[string]interface{} {
return responseutils.GetJSONObject(responseObject, "Labels")
return utils.GetJSONObject(responseObject, "Labels")
}
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
@@ -142,7 +142,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
}
func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
responseObject, err := responseutils.GetResponseAsJSONObject(response)
responseObject, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return err
}
@@ -159,7 +159,7 @@ func (transport *Transport) decorateVolumeCreationResponse(response *http.Respon
responseObject = decorateObject(responseObject, resourceControl)
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
func (transport *Transport) restrictedVolumeOperation(requestPath string, request *http.Request) (*http.Response, error) {
@@ -168,17 +168,18 @@ func (transport *Transport) restrictedVolumeOperation(requestPath string, reques
return transport.rewriteOperation(request, transport.volumeInspectOperation)
}
volumeName := path.Base(requestPath)
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, path.Base(requestPath))
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeName)
if err != nil {
return nil, err
}
if request.Method == http.MethodDelete {
return transport.executeGenericResourceDeletionOperation(request, resourceID, portainer.VolumeResourceControl)
return transport.executeGenericResourceDeletionOperation(request, resourceID, volumeName, portainer.VolumeResourceControl)
}
return transport.restrictedResourceOperation(request, resourceID, portainer.VolumeResourceControl, false)
return transport.restrictedResourceOperation(request, resourceID, volumeName, portainer.VolumeResourceControl, false)
}
func (transport *Transport) getVolumeResourceID(nodename, volumeID string) (string, error) {

View File

@@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
return nil, err
}
transport, err := kubernetes.NewLocalTransport(tokenManager)
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
if err != nil {
return nil, err
}
@@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
endpointURL.Scheme = "http"
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint.ID, tokenManager)
proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.dataStore)
return proxy, nil
}
@@ -103,7 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
}
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager)
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
return proxy, nil
}

View File

@@ -0,0 +1,60 @@
package kubernetes
import (
"crypto/tls"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/kubernetes/cli"
)
type agentTransport struct {
*baseTransport
signatureService portainer.DigitalSignatureService
}
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *agentTransport {
transport := &agentTransport{
baseTransport: newBaseTransport(
&http.Transport{
TLSClientConfig: tlsConfig,
},
tokenManager,
endpoint,
k8sClientFactory,
dataStore,
),
signatureService: signatureService,
}
return transport
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := getRoundTripToken(request, transport.tokenManager)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
if strings.HasPrefix(request.URL.Path, "/v2") {
err := decorateAgentRequest(request, transport.dataStore)
if err != nil {
return nil, err
}
}
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
return transport.baseTransport.RoundTrip(request)
}

View File

@@ -0,0 +1,57 @@
package kubernetes
import (
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/kubernetes/cli"
)
type edgeTransport struct {
*baseTransport
reverseTunnelService portainer.ReverseTunnelService
}
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *edgeTransport {
transport := &edgeTransport{
baseTransport: newBaseTransport(
&http.Transport{},
tokenManager,
endpoint,
k8sClientFactory,
dataStore,
),
reverseTunnelService: reverseTunnelService,
}
return transport
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := getRoundTripToken(request, transport.tokenManager)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
if strings.HasPrefix(request.URL.Path, "/v2") {
err := decorateAgentRequest(request, transport.dataStore)
if err != nil {
return nil, err
}
}
response, err := transport.baseTransport.RoundTrip(request)
if err == nil {
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
}
return response, err
}

View File

@@ -0,0 +1,45 @@
package kubernetes
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/kubernetes/cli"
)
type localTransport struct {
*baseTransport
}
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) (*localTransport, error) {
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
if err != nil {
return nil, err
}
transport := &localTransport{
baseTransport: newBaseTransport(
&http.Transport{
TLSClientConfig: config,
},
tokenManager,
endpoint,
k8sClientFactory,
dataStore,
),
}
return transport, nil
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
_, err := transport.prepareRoundTrip(request)
if err != nil {
return nil, err
}
return transport.baseTransport.RoundTrip(request)
}

View File

@@ -0,0 +1,45 @@
package kubernetes
import (
"net/http"
portainer "github.com/portainer/portainer/api"
)
func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
registries, err := transport.dataStore.Registry().Registries()
if err != nil {
return nil, err
}
for _, registry := range registries {
for endpointID, registryAccessPolicies := range registry.RegistryAccesses {
if endpointID != transport.endpoint.ID {
continue
}
namespaces := []string{}
for _, ns := range registryAccessPolicies.Namespaces {
if ns == namespace {
continue
}
namespaces = append(namespaces, ns)
}
if len(namespaces) != len(registryAccessPolicies.Namespaces) {
updatedAccessPolicies := portainer.RegistryAccessPolicies{
Namespaces: namespaces,
UserAccessPolicies: registryAccessPolicies.UserAccessPolicies,
TeamAccessPolicies: registryAccessPolicies.TeamAccessPolicies,
}
registry.RegistryAccesses[endpointID] = updatedAccessPolicies
err := transport.dataStore.Registry().UpdateRegistry(registry.ID, &registry)
if err != nil {
return nil, err
}
}
}
}
return transport.executeKubernetesRequest(request)
}

View File

@@ -0,0 +1,170 @@
package kubernetes
import (
"net/http"
"path"
"github.com/portainer/portainer/api/http/proxy/factory/utils"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/privateregistries"
v1 "k8s.io/api/core/v1"
)
func (transport *baseTransport) proxySecretRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
switch request.Method {
case "POST":
return transport.proxySecretCreationOperation(request)
case "GET":
if path.Base(requestPath) == "secrets" {
return transport.proxySecretListOperation(request)
}
return transport.proxySecretInspectOperation(request)
case "PUT":
return transport.proxySecretUpdateOperation(request)
case "DELETE":
return transport.proxySecretDeleteOperation(request, namespace)
default:
return transport.executeKubernetesRequest(request)
}
}
func (transport *baseTransport) proxySecretCreationOperation(request *http.Request) (*http.Response, error) {
body, err := utils.GetRequestAsMap(request)
if err != nil {
return nil, err
}
if isSecretRepresentPrivateRegistry(body) {
return utils.WriteAccessDeniedResponse()
}
err = utils.RewriteRequest(request, body)
if err != nil {
return nil, err
}
return transport.executeKubernetesRequest(request)
}
func (transport *baseTransport) proxySecretListOperation(request *http.Request) (*http.Response, error) {
response, err := transport.executeKubernetesRequest(request)
if err != nil {
return nil, err
}
isAdmin, err := security.IsAdmin(request)
if err != nil {
return nil, err
}
if isAdmin {
return response, nil
}
body, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
items := utils.GetArrayObject(body, "items")
if items == nil {
utils.RewriteResponse(response, body, response.StatusCode)
return response, nil
}
filteredItems := []interface{}{}
for _, item := range items {
itemObj := item.(map[string]interface{})
if !isSecretRepresentPrivateRegistry(itemObj) {
filteredItems = append(filteredItems, item)
}
}
body["items"] = filteredItems
utils.RewriteResponse(response, body, response.StatusCode)
return response, nil
}
func (transport *baseTransport) proxySecretInspectOperation(request *http.Request) (*http.Response, error) {
response, err := transport.executeKubernetesRequest(request)
if err != nil {
return nil, err
}
isAdmin, err := security.IsAdmin(request)
if err != nil {
return nil, err
}
if isAdmin {
return response, nil
}
body, err := utils.GetResponseAsJSONObject(response)
if err != nil {
return nil, err
}
if isSecretRepresentPrivateRegistry(body) {
return utils.WriteAccessDeniedResponse()
}
err = utils.RewriteResponse(response, body, response.StatusCode)
if err != nil {
return nil, err
}
return response, nil
}
func (transport *baseTransport) proxySecretUpdateOperation(request *http.Request) (*http.Response, error) {
body, err := utils.GetRequestAsMap(request)
if err != nil {
return nil, err
}
if isSecretRepresentPrivateRegistry(body) {
return utils.WriteAccessDeniedResponse()
}
err = utils.RewriteRequest(request, body)
if err != nil {
return nil, err
}
return transport.executeKubernetesRequest(request)
}
func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
kcl, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint)
if err != nil {
return nil, err
}
secretName := path.Base(request.RequestURI)
isRegistry, err := kcl.IsRegistrySecret(namespace, secretName)
if err != nil {
return nil, err
}
if isRegistry {
return utils.WriteAccessDeniedResponse()
}
return transport.executeKubernetesRequest(request)
}
func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool {
if secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) {
return false
}
metadata := utils.GetJSONObject(secret, "metadata")
annotations := utils.GetJSONObject(metadata, "annotations")
_, ok := annotations[privateregistries.RegistryIDLabel]
return ok
}

View File

@@ -2,153 +2,107 @@ package kubernetes
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
)
type (
localTransport struct {
httpTransport *http.Transport
tokenManager *tokenManager
endpointIdentifier portainer.EndpointID
}
agentTransport struct {
dataStore portainer.DataStore
httpTransport *http.Transport
tokenManager *tokenManager
signatureService portainer.DigitalSignatureService
endpointIdentifier portainer.EndpointID
}
edgeTransport struct {
dataStore portainer.DataStore
httpTransport *http.Transport
tokenManager *tokenManager
reverseTunnelService portainer.ReverseTunnelService
endpointIdentifier portainer.EndpointID
}
)
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) {
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
if err != nil {
return nil, err
}
transport := &localTransport{
httpTransport: &http.Transport{
TLSClientConfig: config,
},
tokenManager: tokenManager,
}
return transport, nil
type baseTransport struct {
httpTransport *http.Transport
tokenManager *tokenManager
endpoint *portainer.Endpoint
k8sClientFactory *cli.ClientFactory
dataStore portainer.DataStore
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *baseTransport {
return &baseTransport{
httpTransport: httpTransport,
tokenManager: tokenManager,
endpoint: endpoint,
k8sClientFactory: k8sClientFactory,
dataStore: dataStore,
}
}
// #region KUBERNETES PROXY
// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based
// on the requested operation.
func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) {
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`)
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
switch {
case strings.EqualFold(requestPath, "/namespaces"):
return transport.executeKubernetesRequest(request)
case strings.HasPrefix(requestPath, "/namespaces"):
return transport.proxyNamespacedRequest(request, requestPath)
default:
return transport.executeKubernetesRequest(request)
}
}
func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) {
requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/")
split := strings.SplitN(requestPath, "/", 2)
namespace := split[0]
requestPath = ""
if len(split) > 1 {
requestPath = split[1]
}
switch {
case strings.HasPrefix(requestPath, "secrets"):
return transport.proxySecretRequest(request, namespace, requestPath)
case requestPath == "" && request.Method == "DELETE":
return transport.proxyNamespaceDeleteOperation(request, namespace)
default:
return transport.executeKubernetesRequest(request)
}
}
func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) {
resp, err := transport.httpTransport.RoundTrip(request)
return resp, err
}
// #endregion
// #region ROUND TRIP
func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) {
token, err := getRoundTripToken(request, transport.tokenManager)
if err != nil {
return nil, err
return "", err
}
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
return transport.httpTransport.RoundTrip(request)
}
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
func NewAgentTransport(datastore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport {
transport := &agentTransport{
dataStore: datastore,
httpTransport: &http.Transport{
TLSClientConfig: tlsConfig,
},
tokenManager: tokenManager,
signatureService: signatureService,
}
return transport
return token, nil
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
if strings.HasPrefix(request.URL.Path, "/v2") {
decorateAgentRequest(request, transport.dataStore)
}
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
return transport.httpTransport.RoundTrip(request)
func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport.proxyKubernetesRequest(request)
}
// NewEdgeTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
func NewEdgeTransport(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport {
transport := &edgeTransport{
dataStore: datastore,
httpTransport: &http.Transport{},
tokenManager: tokenManager,
reverseTunnelService: reverseTunnelService,
endpointIdentifier: endpointIdentifier,
}
return transport
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
if strings.HasPrefix(request.URL.Path, "/v2") {
decorateAgentRequest(request, transport.dataStore)
}
response, err := transport.httpTransport.RoundTrip(request)
if err == nil {
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier)
}
return response, err
}
func getRoundTripToken(
request *http.Request,
tokenManager *tokenManager,
endpointIdentifier portainer.EndpointID,
) (string, error) {
func getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return "", err
@@ -168,32 +122,56 @@ func getRoundTripToken(
return token, nil
}
// #endregion
// #region DECORATE FUNCTIONS
func decorateAgentRequest(r *http.Request, dataStore portainer.DataStore) error {
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
switch {
case strings.HasPrefix(requestPath, "/dockerhub"):
decorateAgentDockerHubRequest(r, dataStore)
return decorateAgentDockerHubRequest(r, dataStore)
}
return nil
}
func decorateAgentDockerHubRequest(r *http.Request, dataStore portainer.DataStore) error {
dockerhub, err := dataStore.DockerHub().DockerHub()
requestPath, registryIdString := path.Split(r.URL.Path)
registryID, err := strconv.Atoi(registryIdString)
if err != nil {
return err
return fmt.Errorf("missing registry id: %w", err)
}
newBody, err := json.Marshal(dockerhub)
r.URL.Path = strings.TrimSuffix(requestPath, "/")
registry := &portainer.Registry{
Type: portainer.DockerHubRegistry,
}
if registryID != 0 {
registry, err = dataStore.Registry().Registry(portainer.RegistryID(registryID))
if err != nil {
return fmt.Errorf("failed fetching registry: %w", err)
}
}
if registry.Type != portainer.DockerHubRegistry {
return errors.New("invalid registry type")
}
newBody, err := json.Marshal(registry)
if err != nil {
return err
return fmt.Errorf("failed marshaling registry: %w", err)
}
r.Method = http.MethodPost
r.Body = ioutil.NopCloser(bytes.NewReader(newBody))
r.ContentLength = int64(len(newBody))
return nil
}
// #endregion

View File

@@ -1,11 +0,0 @@
package responseutils
// GetJSONObject will extract an object from a specific property of another JSON object.
// Returns nil if nothing is associated to the specified key.
func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} {
object := jsonObject[property]
if object != nil {
return object.(map[string]interface{})
}
return nil
}

View File

@@ -0,0 +1,91 @@
package utils
import (
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"gopkg.in/yaml.v3"
)
// GetJSONObject will extract an object from a specific property of another JSON object.
// Returns nil if nothing is associated to the specified key.
func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} {
object := jsonObject[property]
if object != nil {
return object.(map[string]interface{})
}
return nil
}
// GetArrayObject will extract an array from a specific property of another JSON object.
// Returns nil if nothing is associated to the specified key.
func GetArrayObject(jsonObject map[string]interface{}, property string) []interface{} {
object := jsonObject[property]
if object != nil {
return object.([]interface{})
}
return nil
}
func getBody(body io.ReadCloser, contentType string, isGzip bool) (interface{}, error) {
if body == nil {
return nil, errors.New("unable to parse response: empty response body")
}
reader := body
if isGzip {
gzipReader, err := gzip.NewReader(reader)
if err != nil {
return nil, err
}
reader = gzipReader
}
defer reader.Close()
bodyBytes, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
err = body.Close()
if err != nil {
return nil, err
}
var data interface{}
err = unmarshal(contentType, bodyBytes, &data)
if err != nil {
return nil, err
}
return data, nil
}
func marshal(contentType string, data interface{}) ([]byte, error) {
switch contentType {
case "application/yaml":
return yaml.Marshal(data)
case "application/json", "":
return json.Marshal(data)
}
return nil, fmt.Errorf("content type is not supported for marshaling: %s", contentType)
}
func unmarshal(contentType string, body []byte, returnBody interface{}) error {
switch contentType {
case "application/yaml":
return yaml.Unmarshal(body, returnBody)
case "application/json", "":
return json.Unmarshal(body, returnBody)
}
return fmt.Errorf("content type is not supported for unmarshaling: %s", contentType)
}

View File

@@ -0,0 +1,45 @@
package utils
import (
"bytes"
"io/ioutil"
"net/http"
"strconv"
)
// GetRequestAsMap returns the response content as a generic JSON object
func GetRequestAsMap(request *http.Request) (map[string]interface{}, error) {
data, err := getRequestBody(request)
if err != nil {
return nil, err
}
return data.(map[string]interface{}), nil
}
// RewriteRequest will replace the existing request body with the one specified
// in parameters
func RewriteRequest(request *http.Request, newData interface{}) error {
data, err := marshal(getContentType(request.Header), newData)
if err != nil {
return err
}
body := ioutil.NopCloser(bytes.NewReader(data))
request.Body = body
request.ContentLength = int64(len(data))
if request.Header == nil {
request.Header = make(http.Header)
}
request.Header.Set("Content-Length", strconv.Itoa(len(data)))
return nil
}
func getRequestBody(request *http.Request) (interface{}, error) {
isGzip := request.Header.Get("Content-Encoding") == "gzip"
return getBody(request.Body, getContentType(request.Header), isGzip)
}

View File

@@ -1,9 +1,7 @@
package responseutils
package utils
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"io/ioutil"
"log"
@@ -13,7 +11,7 @@ import (
// GetResponseAsJSONObject returns the response content as a generic JSON object
func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, error) {
responseData, err := getResponseBodyAsGenericJSON(response)
responseData, err := getResponseBody(response)
if err != nil {
return nil, err
}
@@ -24,7 +22,7 @@ func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, e
// GetResponseAsJSONArray returns the response content as an array of generic JSON object
func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
responseData, err := getResponseBodyAsGenericJSON(response)
responseData, err := getResponseBody(response)
if err != nil {
return nil, err
}
@@ -44,72 +42,54 @@ func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
}
}
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
if response.Body == nil {
return nil, errors.New("unable to parse response: empty response body")
}
reader := response.Body
if response.Header.Get("Content-Encoding") == "gzip" {
response.Header.Del("Content-Encoding")
gzipReader, err := gzip.NewReader(response.Body)
if err != nil {
return nil, err
}
reader = gzipReader
}
defer reader.Close()
var data interface{}
body, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &data)
if err != nil {
return nil, err
}
return data, nil
}
type dockerErrorResponse struct {
type errorResponse struct {
Message string `json:"message,omitempty"`
}
// WriteAccessDeniedResponse will create a new access denied response
func WriteAccessDeniedResponse() (*http.Response, error) {
response := &http.Response{}
err := RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden)
err := RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden)
return response, err
}
// RewriteAccessDeniedResponse will overwrite the existing response with an access denied response
func RewriteAccessDeniedResponse(response *http.Response) error {
return RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden)
return RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden)
}
// RewriteResponse will replace the existing response body and status code with the one specified
// in parameters
func RewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error {
jsonData, err := json.Marshal(newResponseData)
data, err := marshal(getContentType(response.Header), newResponseData)
if err != nil {
return err
}
body := ioutil.NopCloser(bytes.NewReader(jsonData))
body := ioutil.NopCloser(bytes.NewReader(data))
response.StatusCode = statusCode
response.Body = body
response.ContentLength = int64(len(jsonData))
response.ContentLength = int64(len(data))
if response.Header == nil {
response.Header = make(http.Header)
}
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
response.Header.Set("Content-Length", strconv.Itoa(len(data)))
return nil
}
func getResponseBody(response *http.Response) (interface{}, error) {
isGzip := response.Header.Get("Content-Encoding") == "gzip"
if isGzip {
response.Header.Del("Content-Encoding")
}
return getBody(response.Body, getContentType(response.Header), isGzip)
}
func getContentType(headers http.Header) string {
return headers.Get("Content-type")
}

View File

@@ -1,9 +1,21 @@
package security
import (
"github.com/portainer/portainer/api"
"net/http"
portainer "github.com/portainer/portainer/api"
)
// IsAdmin returns true if the logged-in user is an admin
func IsAdmin(request *http.Request) (bool, error) {
tokenData, err := RetrieveTokenData(request)
if err != nil {
return false, err
}
return tokenData.Role == portainer.AdministratorRole, nil
}
// AuthorizedResourceControlAccess checks whether the user can alter an existing resource control.
func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
if context.IsAdmin || resourceControl.Public {
@@ -95,9 +107,9 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams of the endpoint and the associated group.
func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
groupAccess := authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
groupAccess := AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
if !groupAccess {
return authorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies)
return AuthorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies)
}
return true
}
@@ -106,17 +118,24 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
return AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
}
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, registry.UserAccessPolicies, registry.TeamAccessPolicies)
// listed in the authorized teams for a specified endpoint,
func AuthorizedRegistryAccess(registry *portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) bool {
if user.Role == portainer.AdministratorRole {
return true
}
registryEndpointAccesses := registry.RegistryAccesses[endpointID]
return AuthorizedAccess(user.ID, teamMemberships, registryEndpointAccesses.UserAccessPolicies, registryEndpointAccesses.TeamAccessPolicies)
}
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool {
// AuthorizedAccess verifies the userID or memberships are authorized to use an object per the supplied access policies
func AuthorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool {
_, userAccess := userAccessPolicies[userID]
if userAccess {
return true

View File

@@ -128,31 +128,6 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
return nil
}
// RegistryAccess retrieves the JWT token from the request context and verifies
// that the user can access the specified registry.
// An error is returned when access is denied.
func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portainer.Registry) error {
tokenData, err := RetrieveTokenData(r)
if err != nil {
return err
}
if tokenData.Role == portainer.AdministratorRole {
return nil
}
memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)
if err != nil {
return err
}
if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) {
return httperrors.ErrEndpointAccessDenied
}
return nil
}
// handlers are applied backwards to the incoming request:
// - add secure handlers to the response
// - parse the JWT token and put it into the http context.
@@ -213,7 +188,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
return
}
ctx := storeRestrictedRequestContext(r, requestContext)
ctx := StoreRestrictedRequestContext(r, requestContext)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -5,7 +5,7 @@ import (
"errors"
"net/http"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
)
type (
@@ -33,9 +33,9 @@ func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) {
return tokenData, nil
}
// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context
// StoreRestrictedRequestContext stores a RestrictedRequestContext object inside the request context
// and returns the enhanced context.
func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context {
func StoreRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context {
return context.WithValue(request.Context(), contextRestrictedRequest, requestContext)
}

View File

@@ -1,7 +1,7 @@
package security
import (
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
)
// FilterUserTeams filters teams based on user role.
@@ -64,15 +64,16 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po
// FilterRegistries filters registries based on user role and team memberships.
// Non administrator users only have access to authorized registries.
func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry {
filteredRegistries := registries
if !context.IsAdmin {
filteredRegistries = make([]portainer.Registry, 0)
func FilterRegistries(registries []portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) []portainer.Registry {
if user.Role == portainer.AdministratorRole {
return registries
}
for _, registry := range registries {
if AuthorizedRegistryAccess(&registry, context.UserID, context.UserMemberships) {
filteredRegistries = append(filteredRegistries, registry)
}
filteredRegistries := []portainer.Registry{}
for _, registry := range registries {
if AuthorizedRegistryAccess(&registry, user, teamMemberships, endpointID) {
filteredRegistries = append(filteredRegistries, registry)
}
}

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