feat(app/sources): UAC on sources (#2881)

Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
This commit is contained in:
LP B
2026-06-23 16:30:31 +02:00
committed by GitHub
parent e664bf0e19
commit 56c0ddac85
116 changed files with 2637 additions and 945 deletions
+12 -1
View File
@@ -195,9 +195,20 @@ type (
BucketName() string BucketName() string
} }
SourceServiceUserContext struct {
User *portainer.User
UserMemberships []portainer.TeamMembership
}
// SourceService represents a service for managing GitOps source data // SourceService represents a service for managing GitOps source data
SourceService interface { SourceService interface {
BaseCRUD[portainer.Source, portainer.SourceID] Create(context *SourceServiceUserContext, source *portainer.Source) error
Read(context *SourceServiceUserContext, ID portainer.SourceID) (*portainer.Source, error)
Exists(context *SourceServiceUserContext, ID portainer.SourceID) (bool, error)
ReadAll(context *SourceServiceUserContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error)
Update(context *SourceServiceUserContext, ID portainer.SourceID, source *portainer.Source) error
Delete(context *SourceServiceUserContext, ID portainer.SourceID) error
FindOrCreateGitSource(context *SourceServiceUserContext, source *portainer.Source) (*portainer.Source, error)
} }
// StackService represents a service for managing stack data // StackService represents a service for managing stack data
+133
View File
@@ -0,0 +1,133 @@
package source
import (
"errors"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
)
var (
ErrInvalidSource = errors.New("invalid source")
ErrInvalidUserContext = errors.New("invalid user context")
ErrNotEnoughPermission = errors.New("not enough permissions to perform this action")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
)
func validateUserContext(ctx *userContext) error {
if ctx == nil || ctx.User == nil {
return ErrInvalidUserContext
}
return nil
}
type actionType string
const (
actionRead actionType = "read"
actionWrite actionType = "write"
)
func enforceUserPermissions(ctx *userContext, source *portainer.Source, action actionType) error {
if action == actionRead && userCanReadSource(source, ctx) {
return nil
}
if action == actionWrite && userCanWriteSource(source, ctx) {
return nil
}
return ErrNotEnoughPermission
}
func userCanWriteSource(source *portainer.Source, context *userContext) bool {
if source == nil || context == nil || context.User == nil {
return false
}
user := context.User
if user.Role == portainer.AdministratorRole {
return true
}
if source.OwnerID != 0 && source.OwnerID == user.ID && userCanReadSource(source, context) {
return true
}
return false
}
func filterSources(sources []portainer.Source, context *userContext) []portainer.Source {
return slicesx.Filter(sources, func(s portainer.Source) bool {
return userCanReadSource(&s, context)
})
}
func userCanReadSource(source *portainer.Source, context *userContext) bool {
if source == nil || context == nil || context.User == nil {
return false
}
user := context.User
userTeams := context.UserMemberships
if user.Role == portainer.AdministratorRole || source.Public {
return true
}
if source.AdministratorsOnly {
return false
}
if slices.Contains(source.UserAccesses, user.ID) {
return true
}
if len(userTeams) == 0 || len(source.TeamAccesses) == 0 {
return false
}
sTeams := set.ToSet(source.TeamAccesses)
uTeams := set.ToSet(slicesx.Map(userTeams, func(u portainer.TeamMembership) portainer.TeamID { return u.TeamID }))
return set.Intersection(sTeams, uTeams).Len() != 0
}
// enforceUniqueGitSource validates there are no other git sources with the same URL and credentials
// It ignores itself
func enforceUniqueGitSource(tx ServiceTx, src *portainer.Source) error {
if src.Type != portainer.SourceTypeGit || src.Git == nil {
return nil
}
normalized, err := normalizeGitSource(src)
if err != nil {
return err
}
existing, err := tx.base.ReadAll(func(s portainer.Source) bool {
if src.ID == s.ID {
return false
}
n, err := normalizeGitSource(&s)
if err != nil {
return false
}
return normalized.Equal(n)
})
if err != nil {
return err
}
if len(existing) > 0 {
return ErrDuplicateSource
}
return nil
}
@@ -0,0 +1,43 @@
package source
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
type normalizedGitSource struct {
url string
username string
password string
}
func (a *normalizedGitSource) Equal(b *normalizedGitSource) bool {
return a != nil && b != nil &&
a.url == b.url &&
a.username == b.username &&
a.password == b.password
}
// normalize git source to a lighter object used to compare sources together
func normalizeGitSource(src *portainer.Source) (*normalizedGitSource, error) {
if src == nil || src.Type != portainer.SourceTypeGit || src.Git == nil {
return nil, ErrInvalidSource
}
url, err := gittypes.NormalizeURL(gittypes.SanitizeURL(src.Git.URL))
if err != nil {
return nil, err
}
username, password := "", ""
if src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
return &normalizedGitSource{
url: url,
username: username,
password: password,
}, nil
}
+74
View File
@@ -0,0 +1,74 @@
package source
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
// Sanitize the source URL and enforce fields values based on user context
func sanitizeGitSource(source *portainer.Source) error {
if source == nil {
return ErrInvalidSource
}
if source.Type != portainer.SourceTypeGit {
return nil
}
if source.Git == nil {
return ErrInvalidSource
}
var err error
source.Git.URL, err = gittypes.NormalizeURL(gittypes.SanitizeURL(source.Git.URL))
if err != nil {
return err
}
return nil
}
func sanitizeAccesses(ctx *userContext, newValues *portainer.Source, previousValues *portainer.Source) error {
if newValues == nil {
return ErrInvalidSource
}
if ctx.User.Role == portainer.AdministratorRole {
if newValues.Public && newValues.AdministratorsOnly {
newValues.Public = false
}
if newValues.Public || newValues.AdministratorsOnly {
newValues.UserAccesses = []portainer.UserID{}
newValues.TeamAccesses = []portainer.TeamID{}
}
if !newValues.Public && !newValues.AdministratorsOnly && len(newValues.UserAccesses) == 0 && len(newValues.TeamAccesses) == 0 {
newValues.AdministratorsOnly = true
}
return nil
}
// Update flow ; regular user is not allowed to change the UAC, visibility or ownership of the source
if previousValues != nil {
newValues.UserAccesses = previousValues.UserAccesses
newValues.TeamAccesses = previousValues.TeamAccesses
newValues.Public = previousValues.Public
newValues.AdministratorsOnly = previousValues.AdministratorsOnly
newValues.OwnerID = previousValues.OwnerID
return nil
}
// Create flow
userAccesses := []portainer.UserID{ctx.User.ID}
if newValues.Public {
userAccesses = []portainer.UserID{}
}
newValues.UserAccesses = userAccesses
newValues.TeamAccesses = []portainer.TeamID{}
newValues.AdministratorsOnly = false
newValues.OwnerID = ctx.User.ID
return nil
}
@@ -0,0 +1,72 @@
package source
import (
"testing"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/require"
)
type vFn func(new *portainer.Source, old *portainer.Source, err error)
func testUAC(
t *testing.T,
userContext *userContext,
new *portainer.Source,
old *portainer.Source,
validationFuncs ...vFn,
) {
t.Helper()
err := sanitizeAccesses(userContext, new, old)
for _, validate := range validationFuncs {
validate(new, old, err)
}
}
func Test_SanitizeAccesses_Admin(t *testing.T) {
errInvalidSource := func(_, _ *portainer.Source, err error) {
t.Helper()
require.ErrorIs(t, err, ErrInvalidSource)
}
noError := func(_, _ *portainer.Source, err error) { t.Helper(); require.NoError(t, err) }
noOwner := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Zero(t, new.OwnerID) }
emptyUsers := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.UserAccesses) }
emptyTeams := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.TeamAccesses) }
public := func(v bool) func(new, _ *portainer.Source, _ error) {
return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.Public) }
}
adminOnly := func(v bool) func(new, _ *portainer.Source, _ error) {
return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.AdministratorsOnly) }
}
adminUserContext := NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{})
test := func(new *portainer.Source, old *portainer.Source, validationFuncs ...vFn) {
t.Helper()
testUAC(t, adminUserContext, new, old, validationFuncs...)
}
test(nil, nil, errInvalidSource)
test(&portainer.Source{}, nil, noError)
test(&portainer.Source{Git: &gittypes.RepoConfig{}}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, Public: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(false), noOwner, public(true),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, AdministratorsOnly: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, AdministratorsOnly: true, Public: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.RepoConfig{}, AdministratorsOnly: true, Public: true, UserAccesses: []portainer.UserID{1, 2}}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
}
// func Test_SanitizeAccesses_User(t *testing.T) {
// user := NewUserContext(&portainer.User{Role: portainer.StandardUserRole}, []portainer.TeamMembership{})
// }
+68 -12
View File
@@ -10,7 +10,7 @@ const BucketName = "sources"
// Service represents a service for managing GitOps source data. // Service represents a service for managing GitOps source data.
type Service struct { type Service struct {
dataservices.BaseDataService[portainer.Source, portainer.SourceID] base dataservices.BaseDataService[portainer.Source, portainer.SourceID]
} }
// NewService creates a new instance of a service. // NewService creates a new instance of a service.
@@ -21,7 +21,7 @@ func NewService(connection portainer.Connection) (*Service, error) {
} }
return &Service{ return &Service{
BaseDataService: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{ base: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
Bucket: BucketName, Bucket: BucketName,
Connection: connection, Connection: connection,
}, },
@@ -30,21 +30,77 @@ func NewService(connection portainer.Connection) (*Service, error) {
func (service *Service) Tx(tx portainer.Transaction) ServiceTx { func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{ return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{ base: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
Bucket: BucketName, Bucket: BucketName,
Connection: service.Connection, Connection: service.base.Connection,
Tx: tx, Tx: tx,
}, },
} }
} }
// Create creates a new source. // Create creates a new source.
func (service *Service) Create(source *portainer.Source) error { func (service *Service) Create(context *userContext, source *portainer.Source) error {
return service.Connection.CreateObject( return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
BucketName, return service.Tx(tx).Create(context, source)
func(id uint64) (int, any) { })
source.ID = portainer.SourceID(id) }
return int(source.ID), source
}, func (service *Service) Read(context *userContext, ID portainer.SourceID) (*portainer.Source, error) {
) var result *portainer.Source
err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).Read(context, ID)
return err
})
return result, err
}
func (service *Service) Exists(context *userContext, ID portainer.SourceID) (bool, error) {
var result bool
err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).Exists(context, ID)
return err
})
return result, err
}
func (service *Service) ReadAll(context *userContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) {
var result []portainer.Source
err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).ReadAll(context, predicates...)
return err
})
return result, err
}
func (service *Service) Update(context *userContext, ID portainer.SourceID, source *portainer.Source) error {
return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Update(context, ID, source)
})
}
func (service *Service) Delete(context *userContext, ID portainer.SourceID) error {
return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).Delete(context, ID)
})
}
func (service *Service) FindOrCreateGitSource(context *userContext, source *portainer.Source) (*portainer.Source, error) {
var result *portainer.Source
err := service.base.Connection.UpdateTx(func(tx portainer.Transaction) error {
var err error
result, err = service.Tx(tx).FindOrCreateGitSource(context, source)
return err
})
return result, err
} }
+186 -3
View File
@@ -3,15 +3,36 @@ package source
import ( import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types"
) )
type ServiceTx struct { type ServiceTx struct {
dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID] base dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
} }
// Create creates a new source. // Create creates a new source.
func (service ServiceTx) Create(source *portainer.Source) error { func (service ServiceTx) Create(context *userContext, source *portainer.Source) error {
return service.Tx.CreateObject( if err := validateUserContext(context); err != nil {
return err
}
if source == nil {
return ErrInvalidSource
}
if err := sanitizeGitSource(source); err != nil {
return err
}
if err := sanitizeAccesses(context, source, nil); err != nil {
return err
}
if err := enforceUniqueGitSource(service, source); err != nil {
return err
}
return service.base.Tx.CreateObject(
BucketName, BucketName,
func(id uint64) (int, any) { func(id uint64) (int, any) {
source.ID = portainer.SourceID(id) source.ID = portainer.SourceID(id)
@@ -19,3 +40,165 @@ func (service ServiceTx) Create(source *portainer.Source) error {
}, },
) )
} }
func (service ServiceTx) Read(context *userContext, ID portainer.SourceID) (*portainer.Source, error) {
if err := validateUserContext(context); err != nil {
return nil, err
}
source, err := service.base.Read(ID)
if err != nil {
return nil, err
}
if err := enforceUserPermissions(context, source, actionRead); err != nil {
return nil, err
}
return source, err
}
// Access is not enforced on this to avoid the cost of deserialize
// Any user can scan the DB IDs using this method, so be mindful with usage of this func.
func (service ServiceTx) Exists(context *userContext, ID portainer.SourceID) (bool, error) {
if err := validateUserContext(context); err != nil {
return false, err
}
return service.base.Exists(ID)
}
// ReadAll fetches all sources the user can access, matching predicates
func (service ServiceTx) ReadAll(context *userContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) {
if err := validateUserContext(context); err != nil {
return nil, err
}
list, err := service.base.ReadAll(predicates...)
if err != nil {
return nil, err
}
return filterSources(list, context), nil
}
// Update updates the source of id `ID` with the `source` content
// It validates that the user has access to the source, and has enough permissions to perform the action
func (service ServiceTx) Update(context *userContext, ID portainer.SourceID, source *portainer.Source) error {
if err := validateUserContext(context); err != nil {
return err
}
originalSource, err := service.base.Read(ID)
if err != nil {
return err
}
if source == nil || originalSource == nil {
return ErrInvalidSource
}
if err := enforceUserPermissions(context, originalSource, actionWrite); err != nil {
return err
}
if err := sanitizeGitSource(source); err != nil {
return err
}
if err := sanitizeAccesses(context, source, originalSource); err != nil {
return err
}
if err := enforceUniqueGitSource(service, source); err != nil {
return err
}
return service.base.Update(ID, source)
}
// Delete deletes a source
// It validates that the user has access to the source, and has enough permissions to perform the action
func (service ServiceTx) Delete(context *userContext, ID portainer.SourceID) error {
if err := validateUserContext(context); err != nil {
return err
}
source, err := service.base.Read(ID)
if err != nil {
return err
}
if err := enforceUserPermissions(context, source, actionWrite); err != nil {
return err
}
return service.base.Delete(ID)
}
// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg,
// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source;
// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact.
// The function auto adds the user to an existing source if the user doesn't have access but provided a valid full
// config (URL+Auth)
func (service ServiceTx) FindOrCreateGitSource(context *userContext, src *portainer.Source) (*portainer.Source, error) {
if err := validateUserContext(context); err != nil {
return nil, err
}
if src == nil || src.Git == nil {
return nil, ErrInvalidSource
}
normalized, err := normalizeGitSource(src)
if err != nil {
return nil, err
}
existing, err := service.base.ReadAll(func(s portainer.Source) bool {
n, err := normalizeGitSource(&s)
if err != nil {
return false
}
return normalized.Equal(n)
})
if err != nil {
return nil, err
}
if len(existing) > 0 {
allowed := filterSources(existing, context)
if len(allowed) > 0 {
return &allowed[0], nil
}
// give user access to the first source if he doesn't have access
// to any of the sources that have the same url+auth
existing[0].UserAccesses = append(existing[0].UserAccesses, context.User.ID)
if err := service.base.Update(existing[0].ID, &existing[0]); err != nil {
return nil, err
}
return &existing[0], nil
}
toCreate := &portainer.Source{
Name: src.Name,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: src.Git.URL,
Authentication: src.Git.Authentication,
TLSSkipVerify: src.Git.TLSSkipVerify,
},
Public: src.Public,
AdministratorsOnly: src.AdministratorsOnly,
UserAccesses: src.UserAccesses,
TeamAccesses: src.TeamAccesses,
OwnerID: src.OwnerID,
}
if err := service.Create(context, toCreate); err != nil {
return nil, err
}
return toCreate, nil
}
+21
View File
@@ -0,0 +1,21 @@
package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
type userContext = dataservices.SourceServiceUserContext
// Create a new admin context
//
// # THIS FUNCTION MUST NOT BE USED IN A USER-AWARE FLOW, ONLY FOR MIGRATIONS AND TESTS
//
// The only flows outside of migrations/test allowed to use this func is the datastore.Import/Export for sources
func InsecureNewAdminContext() *userContext {
return NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{})
}
func NewUserContext(user *portainer.User, userMemberships []portainer.TeamMembership) *userContext {
return &userContext{User: user, UserMemberships: userMemberships}
}
@@ -0,0 +1,97 @@
package source
// var adminUserContext = InsecureNewAdminContext()
// func newSourceWithAuth(url, username, password string) *portainer.Source {
// return &portainer.Source{
// Type: portainer.SourceTypeGit,
// Git: &gittypes.RepoConfig{
// URL: url,
// Authentication: &gittypes.GitAuthentication{
// Username: username,
// Password: password,
// },
// },
// }
// }
// func newAuthlessSource(url string) *portainer.Source {
// return &portainer.Source{
// Type: portainer.SourceTypeGit,
// Git: &gittypes.RepoConfig{URL: url},
// }
// }
// func validateUniqueSourceInStore(t *testing.T, tx ServiceTx, url, username, password string, sourceID portainer.SourceID) bool {
// t.Helper()
// var isUnique bool
// require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
// var err error
// isUnique, err =// enforceUniqueGitSource(tx, url, username, password, sourceID)
// return err
// }))
// return isUnique
// }
// func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
// }))
// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
// }
// func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
// }
// func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git"))
// }))
// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
// }
// func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git"))
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
// }
// func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// var srcID portainer.SourceID
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
// if err := tx.Source().Create(adminUserContext, src); err != nil {
// return err
// }
// srcID = src.ID
// return nil
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
// }
+68 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/dataservices/stack" "github.com/portainer/portainer/api/dataservices/stack"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
@@ -55,6 +56,8 @@ type legacyStack struct {
ID int `json:"Id"` ID int `json:"Id"`
GitConfig *legacyRepoConfig `json:"GitConfig"` GitConfig *legacyRepoConfig `json:"GitConfig"`
WorkflowID *int WorkflowID *int
ResourceControl *portainer.ResourceControl `json:"ResourceControl"`
CreatedBy string
} }
// sourceDedupeKey is the identity used to detect duplicate Sources during migration. // sourceDedupeKey is the identity used to detect duplicate Sources during migration.
@@ -98,7 +101,8 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
return err return err
} }
existingSources, err := m.sourceService.ReadAll() adminUserContext := source.InsecureNewAdminContext()
existingSources, err := m.sourceService.ReadAll(adminUserContext)
if err != nil { if err != nil {
return err return err
} }
@@ -122,6 +126,19 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
var newSrcID portainer.SourceID var newSrcID portainer.SourceID
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error { if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(ls.ResourceControl,
func() (portainer.UserID, portainer.UserRole, error) {
user, err := m.userService.Tx(tx).UserByUsername(ls.CreatedBy)
if err != nil {
return 0, 0, err
}
return user.ID, user.Role, nil
},
func(userId portainer.UserID) ([]portainer.TeamMembership, error) {
return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId)
},
)
srcID, exists := sourcesByKey[key] srcID, exists := sourcesByKey[key]
if !exists { if !exists {
@@ -129,12 +146,29 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error {
Name: gittypes.RepoName(cfg.URL), Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: cfg, Git: cfg,
OwnerID: ownerId,
Public: public,
AdministratorsOnly: adminOnly,
UserAccesses: users,
TeamAccesses: teams,
} }
if err := m.sourceService.Tx(tx).Create(src); err != nil {
if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil {
return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err) return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err)
} }
srcID = src.ID srcID = src.ID
newSrcID = src.ID newSrcID = src.ID
} else {
src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID)
if err != nil {
return fmt.Errorf("failed to read source %d for stack %d: %w", srcID, ls.ID, err)
}
ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId)
if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil {
return fmt.Errorf("failed to update source %d for stack %d: %w", srcID, ls.ID, err)
}
} }
liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID)) liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID))
@@ -182,7 +216,8 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
return err return err
} }
existingSources, err := m.sourceService.ReadAll() adminUserContext := source.InsecureNewAdminContext()
existingSources, err := m.sourceService.ReadAll(adminUserContext)
if err != nil { if err != nil {
return err return err
} }
@@ -211,6 +246,19 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
var newSrcID portainer.SourceID var newSrcID portainer.SourceID
if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error { if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error {
users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(t.ResourceControl,
func() (portainer.UserID, portainer.UserRole, error) {
user, err := m.userService.Tx(tx).Read(t.CreatedByUserID)
if err != nil {
return 0, 0, err
}
return user.ID, user.Role, nil
},
func(userId portainer.UserID) ([]portainer.TeamMembership, error) {
return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId)
},
)
srcID, exists := sourcesByKey[key] srcID, exists := sourcesByKey[key]
if !exists { if !exists {
@@ -218,12 +266,28 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error {
Name: gittypes.RepoName(cfg.URL), Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: cfg, Git: cfg,
OwnerID: ownerId,
Public: public,
AdministratorsOnly: adminOnly,
UserAccesses: users,
TeamAccesses: teams,
} }
if err := m.sourceService.Tx(tx).Create(src); err != nil { if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil {
return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err) return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err)
} }
srcID = src.ID srcID = src.ID
newSrcID = src.ID newSrcID = src.ID
} else {
src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID)
if err != nil {
return fmt.Errorf("failed to read source %d for custom template %d: %w", srcID, t.ID, err)
}
ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId)
if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil {
return fmt.Errorf("failed to update source %d for custom template %d: %w", srcID, t.ID, err)
}
} }
t.Artifact = &portainer.Artifact{ t.Artifact = &portainer.Artifact{
+13 -9
View File
@@ -15,6 +15,10 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// TODO: generate tests for UAC migrations
var adminUserContext = source.InsecureNewAdminContext()
func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) { func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) {
t.Parallel() t.Parallel()
@@ -61,7 +65,7 @@ func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) {
require.Len(t, wf.Artifacts, 1) require.Len(t, wf.Artifacts, 1)
require.Len(t, wf.Artifacts[0].Files, 1) require.Len(t, wf.Artifacts[0].Files, 1)
src, err := sourceSvc.Read(wf.Artifacts[0].Files[0].SourceID) src, err := sourceSvc.Read(adminUserContext, wf.Artifacts[0].Files[0].SourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, gitStack.GitConfig.URL, src.Git.URL) require.Equal(t, gitStack.GitConfig.URL, src.Git.URL)
@@ -104,7 +108,7 @@ func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) {
require.Zero(t, result.WorkflowID) require.Zero(t, result.WorkflowID)
require.Nil(t, result.GitConfig) require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, sources) require.Empty(t, sources)
@@ -160,7 +164,7 @@ func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T)
err = m.migrateGitConfigToSources_2_43_0() err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err) require.NoError(t, err)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 1, "two stacks with the same URL must share one Source") require.Len(t, sources, 1, "two stacks with the same URL must share one Source")
@@ -214,7 +218,7 @@ func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) {
err = m.migrateGitConfigToSources_2_43_0() err = m.migrateGitConfigToSources_2_43_0()
require.NoError(t, err) require.NoError(t, err)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 1) require.Len(t, sources, 1)
@@ -268,7 +272,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *t
require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path) require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path)
require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash) require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash)
src, err := sourceSvc.Read(migrated.Artifact.Files[0].SourceID) src, err := sourceSvc.Read(adminUserContext, migrated.Artifact.Files[0].SourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL) require.Equal(t, "https://github.com/example/repo", src.Git.URL)
@@ -307,7 +311,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched(
require.Nil(t, result.Artifact) require.Nil(t, result.Artifact)
require.Nil(t, result.GitConfig) require.Nil(t, result.GitConfig)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, sources) require.Empty(t, sources)
} }
@@ -350,7 +354,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t
err = m.migrateCustomTemplateGitConfigToSources_2_43_0() err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err) require.NoError(t, err)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, sources, "no new sources should be created for already-migrated templates") require.Empty(t, sources, "no new sources should be created for already-migrated templates")
} }
@@ -402,7 +406,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(
err = m.migrateCustomTemplateGitConfigToSources_2_43_0() err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err) require.NoError(t, err)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source") require.Len(t, sources, 1, "two templates with the same URL must share one Source")
@@ -456,7 +460,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T)
err = m.migrateCustomTemplateGitConfigToSources_2_43_0() err = m.migrateCustomTemplateGitConfigToSources_2_43_0()
require.NoError(t, err) require.NoError(t, err)
sources, err := sourceSvc.ReadAll() sources, err := sourceSvc.ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 1) require.Len(t, sources, 1)
} }
@@ -0,0 +1,122 @@
package migrator
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx"
"github.com/rs/zerolog/log"
)
// DB accesses enforcement are trying to restrict accesses as much as possible
// but because accesses are applied sequentially, we want the accesses to be more open on migration
// so that users retain their accesses
func ApplyUACOnSourceUpdate_2_43_0(source *portainer.Source,
users []portainer.UserID, teams []portainer.TeamID,
public bool, adminOnly bool,
ownerId portainer.UserID,
) {
// sources already public should remain public
// OR
// the resource using this source is public, so the source should be public
if source.Public || public {
source.Public = true
source.AdministratorsOnly = false
source.UserAccesses = []portainer.UserID{}
source.TeamAccesses = []portainer.TeamID{}
return
}
// add users and teams to source accesses only if the incoming resource is not admninonly
// to avoid saving leftover user/teams from adminonly resources
if !adminOnly {
source.UserAccesses = slicesx.Unique(append(source.UserAccesses, users...))
source.TeamAccesses = slicesx.Unique(append(source.TeamAccesses, teams...))
}
// regardless of the incoming resource's ResourceControl values (func params)
// no accesses means adminonly source not owned by anyone
// we don't want users to own sources they don't have access to
// neither we want to default them to public
// all in all as we are doing an update it's probably redundant, but just in case...
if len(source.UserAccesses) == 0 && len(source.TeamAccesses) == 0 {
source.AdministratorsOnly = true
source.OwnerID = 0
return
}
// if owner of the incoming resource (ownerid) is the only one with access, we give the ownership to the user.
// The source could previously be adminonly so we change that as well as we want the most open situation
if len(source.UserAccesses) == 1 && len(source.TeamAccesses) == 0 && ownerId == source.UserAccesses[0] {
source.OwnerID = ownerId
source.AdministratorsOnly = false
return
}
// Anything else will have multiple accesses (multiple teams or users), from multiple resources (source update flow)
// So we remove the ownership of the source in case it existed
// Scenario:
// - source created for resource owned by Bob
// - now we try to update with the RC from an admin-owned resource, shared to other users/teams
// - we don't want Bob to own the source anymore
source.OwnerID = 0
}
func GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(
rc *portainer.ResourceControl,
getCreator func() (portainer.UserID, portainer.UserRole, error),
getCreatorMemberships func(portainer.UserID) ([]portainer.TeamMembership, error),
) (
users []portainer.UserID, teams []portainer.TeamID,
public bool, adminOnly bool,
ownerId portainer.UserID,
) {
users = []portainer.UserID{}
teams = []portainer.TeamID{}
public = false
adminOnly = true
if rc == nil {
return
}
adminOnly = rc.AdministratorsOnly
public = rc.Public
if adminOnly || public {
return
}
// only transfer users/teams when the stack is not admin nor public
// this allows avoiding transfering access of sources to users/teams that don't have real access to the stack
// but that may have had their accesses retained in DB
users = slicesx.Map(rc.UserAccesses, func(ura portainer.UserResourceAccess) portainer.UserID { return ura.UserID })
teams = slicesx.Map(rc.TeamAccesses, func(tra portainer.TeamResourceAccess) portainer.TeamID { return tra.TeamID })
userId, userRole, err := getCreator()
if err != nil {
log.Error().Err(err).Msgf("failed to read user when migrating to source")
return
}
// we don't want to save the ownerid if the user is admin
// this avoids admins taking ownership of a new source
if userRole == portainer.AdministratorRole {
return
}
// We also don't want to get the ownerid if the user doesn't have access to the resource anymore
userTeams, err := getCreatorMemberships(userId)
if err != nil {
log.Error().Err(err).Msgf("failed to read user %d teams when migrating source", userId)
return
}
teamIds := slicesx.Map(userTeams, func(membership portainer.TeamMembership) portainer.TeamID { return membership.TeamID })
if authorization.UserCanAccessResource(userId, teamIds, rc) {
ownerId = userId
}
return
}
+2 -2
View File
@@ -577,7 +577,7 @@ func (store *Store) Export(filename string) (err error) {
backup.SSLSettings = *settings backup.SSLSettings = *settings
} }
if s, err := store.Source().ReadAll(); err != nil { if s, err := store.Source().ReadAll(source.InsecureNewAdminContext()); err != nil {
if !store.IsErrObjectNotFound(err) { if !store.IsErrObjectNotFound(err) {
log.Error().Err(err).Msg("exporting Sources") log.Error().Err(err).Msg("exporting Sources")
} }
@@ -768,7 +768,7 @@ func (store *Store) Import(filename string) (err error) {
} }
for _, v := range backup.Source { for _, v := range backup.Source {
if err := store.Source().Update(v.ID, &v); err != nil { if err := store.Source().Update(source.InsecureNewAdminContext(), v.ID, &v); err != nil {
log.Warn().Err(err).Msg("failed to update the source in the database") log.Warn().Err(err).Msg("failed to update the source in the database")
} }
} }
+5
View File
@@ -0,0 +1,5 @@
package sources
import "github.com/portainer/portainer/api/dataservices/source"
var adminUserContext = source.InsecureNewAdminContext()
+3 -2
View File
@@ -2,6 +2,7 @@ package sources
import ( import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips" "github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -23,14 +24,14 @@ type RepoConfigInput struct {
} }
// ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields. // ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields.
func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) { func ResolveRepoConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) {
cfg := gittypes.RepoConfig{ cfg := gittypes.RepoConfig{
ReferenceName: input.ReferenceName, ReferenceName: input.ReferenceName,
ConfigFilePath: input.ConfigFilePath, ConfigFilePath: input.ConfigFilePath,
} }
if input.SourceID != 0 { if input.SourceID != 0 {
src, httpErr := ValidateGitSourceAccess(tx, input.SourceID) src, httpErr := ValidateGitSourceAccess(tx, userContext, input.SourceID)
if httpErr != nil { if httpErr != nil {
return gittypes.RepoConfig{}, httpErr return gittypes.RepoConfig{}, httpErr
} }
+3 -3
View File
@@ -30,9 +30,9 @@ func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) {
}, },
}, },
} }
require.NoError(t, store.Source().Create(src)) require.NoError(t, store.Source().Create(adminUserContext, src))
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{ cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
SourceID: src.ID, SourceID: src.ID,
ReferenceName: "refs/heads/main", ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml", ConfigFilePath: "docker-compose.yml",
@@ -51,7 +51,7 @@ func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) {
t.Parallel() t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false) _, store := datastore.MustNewTestStore(t, false, false)
cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{ cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{
ReferenceName: "refs/heads/main", ReferenceName: "refs/heads/main",
ConfigFilePath: "docker-compose.yml", ConfigFilePath: "docker-compose.yml",
RepositoryURL: "https://github.com/org/repo", RepositoryURL: "https://github.com/org/repo",
+2 -3
View File
@@ -16,9 +16,8 @@ type gitSourceStore interface {
} }
// ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it. // ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it.
// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced. func ValidateGitSourceAccess(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) {
func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) { src, err := tx.Source().Read(userContext, sourceID)
src, err := tx.Source().Read(sourceID)
if err != nil { if err != nil {
if tx.IsErrObjectNotFound(err) { if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Source not found", err) return nil, httperror.NotFound("Source not found", err)
+3 -17
View File
@@ -19,9 +19,9 @@ func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"},
} }
require.NoError(t, store.Source().Create(src)) require.NoError(t, store.Source().Create(adminUserContext, src))
_, httpErr := ValidateGitSourceAccess(store, src.ID) _, httpErr := ValidateGitSourceAccess(store, adminUserContext, src.ID)
assert.Nil(t, httpErr) assert.Nil(t, httpErr)
} }
@@ -29,21 +29,7 @@ func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) {
t.Parallel() t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false) _, store := datastore.MustNewTestStore(t, false, false)
_, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999)) _, httpErr := ValidateGitSourceAccess(store, adminUserContext, portainer.SourceID(999))
require.NotNil(t, httpErr) require.NotNil(t, httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
} }
func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, false)
src := &portainer.Source{
Type: portainer.SourceType(99), // not a git source
}
require.NoError(t, store.Source().Create(src))
_, httpErr := ValidateGitSourceAccess(store, src.ID)
require.NotNil(t, httpErr)
assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode)
}
+7 -2
View File
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
@@ -22,6 +23,8 @@ func FetchWorkflows(
) ([]Workflow, error) { ) ([]Workflow, error) {
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{} gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
}) })
@@ -50,7 +53,7 @@ func FetchWorkflows(
workflowIDSet.Add(stack.WorkflowID) workflowIDSet.Add(stack.WorkflowID)
} }
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, workflowIDSet) workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -113,7 +116,9 @@ func FetchSourceStats(
k8sFactory *cli.ClientFactory, k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext, sc *security.RestrictedRequestContext,
) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) { ) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) {
sources, err := tx.Source().ReadAll() userContext := source.NewUserContext(sc.User, sc.UserMemberships)
sources, err := tx.Source().ReadAll(userContext)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
+12 -8
View File
@@ -15,7 +15,11 @@ import (
) )
func adminContext() *security.RestrictedRequestContext { func adminContext() *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{IsAdmin: true, UserID: 1} return &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
} }
func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) { func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) {
@@ -24,7 +28,7 @@ func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *por
cfg := stack.GitConfig cfg := stack.GitConfig
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: cfg} src := &portainer.Source{Type: portainer.SourceTypeGit, Git: cfg}
require.NoError(t, tx.Source().Create(src)) require.NoError(t, tx.Source().Create(adminUserContext, src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID, StackID: stack.ID,
@@ -199,8 +203,8 @@ func TestFetchSourceStats_ReturnsAllSources(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit})) require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo1"}}))
require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit})) require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo2"}}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
})) }))
@@ -223,8 +227,8 @@ func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) {
var srcID portainer.SourceID var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
require.NoError(t, tx.Source().Create(src)) require.NoError(t, tx.Source().Create(adminUserContext, src))
srcID = src.ID srcID = src.ID
for i := 1; i <= 2; i++ { for i := 1; i <= 2; i++ {
@@ -261,8 +265,8 @@ func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) {
var unusedID portainer.SourceID var unusedID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
require.NoError(t, tx.Source().Create(src)) require.NoError(t, tx.Source().Create(adminUserContext, src))
unusedID = src.ID unusedID = src.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
+7
View File
@@ -0,0 +1,7 @@
package workflows
import (
"github.com/portainer/portainer/api/dataservices/source"
)
var adminUserContext = source.InsecureNewAdminContext()
+23 -98
View File
@@ -20,7 +20,7 @@ type gitSourceStore interface {
// from the workflow identified by workflowID. // from the workflow identified by workflowID.
// Source carries the shared fields (URL, auth, TLS); ArtifactFile carries the file-specific fields (ref, path, hash). // Source carries the shared fields (URL, auth, TLS); ArtifactFile carries the file-specific fields (ref, path, hash).
// Returns nil, nil, nil when workflowID is 0 or no matching entry is found. // Returns nil, nil, nil when workflowID is 0 or no matching entry is found.
func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) { func GitSourceAndArtifactForStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) {
if workflowID == 0 { if workflowID == 0 {
return nil, nil, nil return nil, nil, nil
} }
@@ -30,7 +30,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.Workfl
return nil, nil, err return nil, nil, err
} }
sourceMap, err := loadWorkflowSources(tx, wf) sourceMap, err := loadWorkflowSources(tx, userContext, wf)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -57,7 +57,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.Workfl
// GitSourceAndArtifactForEdgeStack returns the git Source and the ArtifactFile matching edgeStackID. // GitSourceAndArtifactForEdgeStack returns the git Source and the ArtifactFile matching edgeStackID.
// Returns nil, nil, nil when workflowID is 0 or no matching entry is found. // Returns nil, nil, nil when workflowID is 0 or no matching entry is found.
func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) { func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) {
if workflowID == 0 { if workflowID == 0 {
return nil, nil, nil return nil, nil, nil
} }
@@ -67,7 +67,7 @@ func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, workflowID portainer.Wo
return nil, nil, err return nil, nil, err
} }
sourceMap, err := loadWorkflowSources(tx, wf) sourceMap, err := loadWorkflowSources(tx, userContext, wf)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -169,45 +169,15 @@ func UpdateArtifactFileForEdgeStack(tx gitSourceStore, workflowID portainer.Work
// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg, // FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg,
// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source; // or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source;
// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact. // per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact.
func FindOrCreateGitSource(tx gitSourceStore, src *portainer.Source) (*portainer.Source, error) { func FindOrCreateGitSource(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, src *portainer.Source) (*portainer.Source, error) {
src.Git.URL = gittypes.SanitizeURL(src.Git.URL) return tx.Source().FindOrCreateGitSource(userContext, src)
existing, err := tx.Source().ReadAll(func(s portainer.Source) bool {
return s.Type == portainer.SourceTypeGit &&
s.Git != nil &&
s.Git.URL == src.Git.URL &&
gitAuthMatches(s.Git.Authentication, src.Git.Authentication)
})
if err != nil {
return nil, err
}
if len(existing) > 0 {
return &existing[0], nil
}
toCreate := &portainer.Source{
Name: src.Name,
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: src.Git.URL,
Authentication: src.Git.Authentication,
TLSSkipVerify: src.Git.TLSSkipVerify,
},
}
if err := tx.Source().Create(toCreate); err != nil {
return nil, err
}
return toCreate, nil
} }
// SaveWorkflowGitConfig persists URL/auth/TLS on the Source and ref/path/hash on the Artifact // SaveWorkflowGitConfig persists URL/auth/TLS on the Source and ref/path/hash on the Artifact
// matched by matchArtifact. When the URL changes, an existing or new Source is located via // matched by matchArtifact. When the URL changes, an existing or new Source is located via
// FindOrCreateGitSource and the Workflow's SourceID is updated atomically alongside the Artifact fields. // FindOrCreateGitSource and the Workflow's SourceID is updated atomically alongside the Artifact fields.
func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { func SaveWorkflowGitConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
src, err := tx.Source().Read(oldSourceID) src, err := tx.Source().Read(userContext, oldSourceID)
if err != nil { if err != nil {
return fmt.Errorf("failed to read source: %w", err) return fmt.Errorf("failed to read source: %w", err)
} }
@@ -219,7 +189,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m
newSourceID := oldSourceID newSourceID := oldSourceID
if cfg.URL != src.Git.URL { if cfg.URL != src.Git.URL {
newSrc, err := FindOrCreateGitSource(tx, &portainer.Source{ newSrc, err := FindOrCreateGitSource(tx, userContext, &portainer.Source{
Name: gittypes.RepoName(cfg.URL), Name: gittypes.RepoName(cfg.URL),
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: cfg, Git: cfg,
@@ -233,7 +203,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m
src.Git.Authentication = cfg.Authentication src.Git.Authentication = cfg.Authentication
src.Git.TLSSkipVerify = cfg.TLSSkipVerify src.Git.TLSSkipVerify = cfg.TLSSkipVerify
if err := tx.Source().Update(src.ID, src); err != nil { if err := tx.Source().Update(userContext, src.ID, src); err != nil {
return fmt.Errorf("failed to update source: %w", err) return fmt.Errorf("failed to update source: %w", err)
} }
} }
@@ -297,7 +267,7 @@ func LoadWorkflowMap(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[
// LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference, // LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference,
// collecting source IDs in a single pass over the workflows. // collecting source IDs in a single pass over the workflows.
func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) { func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) {
wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids)) wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids))
sourceIDs := make(set.Set[portainer.SourceID]) sourceIDs := make(set.Set[portainer.SourceID])
for id := range ids { for id := range ids {
@@ -313,7 +283,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.Workflow
} }
} }
srcMap, err := LoadSourceMap(tx, sourceIDs) srcMap, err := loadSourceMap(tx, userContext, sourceIDs)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -323,7 +293,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.Workflow
// loadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map. // loadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map.
// This avoids reading the same Source record more than once when files share a SourceID. // This avoids reading the same Source record more than once when files share a SourceID.
func loadWorkflowSources(tx gitSourceStore, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) { func loadWorkflowSources(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
ids := make(set.Set[portainer.SourceID]) ids := make(set.Set[portainer.SourceID])
for _, as := range wf.Artifacts { for _, as := range wf.Artifacts {
for _, f := range as.Files { for _, f := range as.Files {
@@ -331,67 +301,22 @@ func loadWorkflowSources(tx gitSourceStore, wf *portainer.Workflow) (map[portain
} }
} }
return LoadSourceMap(tx, ids) return loadSourceMap(tx, userContext, ids)
} }
// LoadSourceMap fetches sources by their IDs and returns them keyed by ID. // loadSourceMap fetches sources by their IDs and returns them keyed by ID.
func LoadSourceMap(tx gitSourceStore, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) { func loadSourceMap(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) {
result := make(map[portainer.SourceID]portainer.Source, len(ids)) sources, err := tx.Source().ReadAll(userContext, func(s portainer.Source) bool {
for id := range ids { return ids.Contains(s.ID)
src, err := tx.Source().Read(id) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
result[id] = *src
result := make(map[portainer.SourceID]portainer.Source, len(ids))
for _, src := range sources {
result[src.ID] = src
} }
return result, nil return result, nil
} }
func gitAuthMatches(a, b *gittypes.GitAuthentication) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.Username == b.Username && a.Password == b.Password
}
// ValidateUniqueSource validates there are no other sources with the same URL and credentials.
// Pass empty strings for username and password when the source has no authentication.
func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sourceID portainer.SourceID) (bool, error) {
normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url))
if err != nil {
return false, err
}
existing, err := tx.Source().ReadAll(func(s portainer.Source) bool {
if s.ID == sourceID || s.Type != portainer.SourceTypeGit || s.Git == nil {
return false
}
normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.Git.URL))
if err != nil || normalized != normalizedURL {
return false
}
existingUsername, existingPassword := gitAuthCredentials(s.Git.Authentication)
return existingUsername == username && existingPassword == password
})
if err != nil {
return false, err
}
return len(existing) == 0, nil
}
func gitAuthCredentials(auth *gittypes.GitAuthentication) (username, password string) {
if auth == nil {
return "", ""
}
return auth.Username, auth.Password
}
+40 -205
View File
@@ -80,7 +80,7 @@ func TestGitSourceAndArtifactForStack_ZeroWorkflowIDReturnsNil(t *testing.T) {
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err := store.ViewTx(func(tx dataservices.DataStoreTx) error { err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, 0, 1) src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, 0, 1)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -98,7 +98,7 @@ func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T)
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
} }
err := tx.Source().Create(gitSrc) err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err) require.NoError(t, err)
wf := &portainer.Workflow{ wf := &portainer.Workflow{
@@ -124,7 +124,7 @@ func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T)
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 42) src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 42)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -146,7 +146,7 @@ func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T)
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
wf := &portainer.Workflow{ wf := &portainer.Workflow{
@@ -167,43 +167,7 @@ func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T)
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 99) src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 99)
return txErr
})
require.NoError(t, err)
require.Nil(t, src)
require.Nil(t, file)
}
func TestGitSourceAndArtifactForStack_NonGitSourceSkipped(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
nonGitSrc := &portainer.Source{Type: portainer.SourceType(99)}
err := tx.Source().Create(nonGitSrc)
require.NoError(t, err)
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{SourceID: nonGitSrc.ID}},
}},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
var src *portainer.Source
var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 1)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -219,7 +183,7 @@ func TestGitSourceAndArtifactForEdgeStack_ZeroWorkflowIDReturnsNil(t *testing.T)
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err := store.ViewTx(func(tx dataservices.DataStoreTx) error { err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, 0, 1) src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, 0, 1)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -237,7 +201,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/edge-repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/edge-repo"},
} }
err := tx.Source().Create(gitSrc) err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err) require.NoError(t, err)
wf := &portainer.Workflow{ wf := &portainer.Workflow{
@@ -262,7 +226,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 5) src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 5)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -280,7 +244,7 @@ func TestUpdateArtifactFileForStack_NoMatchingArtifactIsNoOp(t *testing.T) {
var sourceID portainer.SourceID var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
sourceID = src.ID sourceID = src.ID
@@ -318,7 +282,7 @@ func TestUpdateArtifactFileForStack_AppliesFnAndPersists(t *testing.T) {
var sourceID portainer.SourceID var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
sourceID = src.ID sourceID = src.ID
@@ -356,7 +320,7 @@ func TestUpdateArtifactFileForEdgeStack_AppliesFnAndPersists(t *testing.T) {
var sourceID portainer.SourceID var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
sourceID = src.ID sourceID = src.ID
@@ -393,7 +357,7 @@ func TestFindOrCreateGitSource_CreatesNewSource(t *testing.T) {
var src *portainer.Source var src *portainer.Source
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, txErr = FindOrCreateGitSource(tx, &portainer.Source{ src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Name: "my-repo", Name: "my-repo",
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
@@ -413,7 +377,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
makeSource := func(tx dataservices.DataStoreTx) (*portainer.Source, error) { makeSource := func(tx dataservices.DataStoreTx) (*portainer.Source, error) {
return FindOrCreateGitSource(tx, &portainer.Source{ return FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
@@ -446,7 +410,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing.
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, firstID, secondID) require.Equal(t, firstID, secondID)
sources, err := store.Source().ReadAll() sources, err := store.Source().ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 1) require.Len(t, sources, 1)
} }
@@ -456,7 +420,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, txErr := FindOrCreateGitSource(tx, &portainer.Source{ _, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
@@ -468,7 +432,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, txErr := FindOrCreateGitSource(tx, &portainer.Source{ _, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
@@ -479,7 +443,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
sources, err := store.Source().ReadAll() sources, err := store.Source().ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 2) require.Len(t, sources, 2)
} }
@@ -503,7 +467,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
}, },
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
sourceID = src.ID sourceID = src.ID
@@ -539,7 +503,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
} }
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1 return a.StackID == 1
}, sourceID, newCfg) }, sourceID, newCfg)
}) })
@@ -552,7 +516,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T
require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash)
require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID) require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID)
src, err := store.Source().Read(sourceID) src, err := store.Source().Read(adminUserContext, sourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "new-user", src.Git.Authentication.Username) require.Equal(t, "new-user", src.Git.Authentication.Username)
require.Equal(t, "new-pass", src.Git.Authentication.Password) require.Equal(t, "new-pass", src.Git.Authentication.Password)
@@ -571,7 +535,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"},
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
oldSourceID = src.ID oldSourceID = src.ID
@@ -592,7 +556,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/new-repo"} newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/new-repo"}
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1 return a.StackID == 1
}, oldSourceID, newCfg) }, oldSourceID, newCfg)
}) })
@@ -603,7 +567,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) {
newSourceID := wf.Artifacts[0].Files[0].SourceID newSourceID := wf.Artifacts[0].Files[0].SourceID
require.NotEqual(t, oldSourceID, newSourceID) require.NotEqual(t, oldSourceID, newSourceID)
newSrc, err := store.Source().Read(newSourceID) newSrc, err := store.Source().Read(adminUserContext, newSourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://github.com/example/new-repo", newSrc.Git.URL) require.Equal(t, "https://github.com/example/new-repo", newSrc.Git.URL)
} }
@@ -620,7 +584,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"},
} }
err := tx.Source().Create(old) err := tx.Source().Create(adminUserContext, old)
require.NoError(t, err) require.NoError(t, err)
oldSourceID = old.ID oldSourceID = old.ID
@@ -628,7 +592,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
} }
err = tx.Source().Create(existing) err = tx.Source().Create(adminUserContext, existing)
require.NoError(t, err) require.NoError(t, err)
existingSourceID = existing.ID existingSourceID = existing.ID
@@ -649,7 +613,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"} newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"}
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1 return a.StackID == 1
}, oldSourceID, newCfg) }, oldSourceID, newCfg)
}) })
@@ -659,46 +623,11 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, existingSourceID, wf.Artifacts[0].Files[0].SourceID) require.Equal(t, existingSourceID, wf.Artifacts[0].Files[0].SourceID)
sources, err := store.Source().ReadAll() sources, err := store.Source().ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 2) require.Len(t, sources, 2)
} }
func TestSaveWorkflowGitConfig_NilGitConfigReturnsError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var workflowID portainer.WorkflowID
var sourceID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
sourceID = src.ID
wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{
StackID: 1,
Files: []portainer.ArtifactFile{{SourceID: sourceID}},
}},
}
err = tx.Workflow().Create(wf)
require.NoError(t, err)
workflowID = wf.ID
return nil
})
require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1
}, sourceID, &gittypes.RepoConfig{URL: "https://github.com/example/repo"})
})
require.Error(t, err)
}
func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) { func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
t.Parallel() t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
@@ -711,7 +640,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
sourceID = src.ID sourceID = src.ID
@@ -736,7 +665,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool {
return a.StackID == 1 return a.StackID == 1
}, sourceID, &gittypes.RepoConfig{ }, sourceID, &gittypes.RepoConfig{
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
@@ -759,7 +688,7 @@ func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *test
var srcID portainer.SourceID var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -804,7 +733,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
} }
err := tx.Source().Create(old) err := tx.Source().Create(adminUserContext, old)
require.NoError(t, err) require.NoError(t, err)
oldSourceID = old.ID oldSourceID = old.ID
@@ -818,7 +747,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
}, },
}, },
} }
err = tx.Source().Create(selected) err = tx.Source().Create(adminUserContext, selected)
require.NoError(t, err) require.NoError(t, err)
newSourceID = selected.ID newSourceID = selected.ID
@@ -861,7 +790,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) {
require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash)
// The selected source's git config must be left untouched. // The selected source's git config must be left untouched.
selected, err := store.Source().Read(newSourceID) selected, err := store.Source().Read(adminUserContext, newSourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", selected.Git.URL) require.Equal(t, "https://github.com/example/repo", selected.Git.URL)
require.Equal(t, "selected-user", selected.Git.Authentication.Username) require.Equal(t, "selected-user", selected.Git.Authentication.Username)
@@ -876,7 +805,7 @@ func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t *
var srcID portainer.SourceID var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -919,7 +848,7 @@ func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
sourceID = src.ID sourceID = src.ID
@@ -971,7 +900,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"},
} }
err := tx.Source().Create(gitSrc) err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err) require.NoError(t, err)
wf := &portainer.Workflow{ wf := &portainer.Workflow{
@@ -992,7 +921,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 20) src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 20)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -1012,7 +941,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"},
} }
err := tx.Source().Create(gitSrc) err := tx.Source().Create(adminUserContext, gitSrc)
require.NoError(t, err) require.NoError(t, err)
wf := &portainer.Workflow{ wf := &portainer.Workflow{
@@ -1033,7 +962,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t *
var file *portainer.ArtifactFile var file *portainer.ArtifactFile
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 20) src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 20)
return txErr return txErr
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -1070,7 +999,7 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
var src *portainer.Source var src *portainer.Source
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
var txErr error var txErr error
src, txErr = FindOrCreateGitSource(tx, &portainer.Source{ src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
URL: "https://user:secret@github.com/example/repo", URL: "https://user:secret@github.com/example/repo",
@@ -1081,97 +1010,3 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://github.com/example/repo", src.Git.URL) require.Equal(t, "https://github.com/example/repo", src.Git.URL)
} }
func newSourceWithAuth(url, username, password string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{
URL: url,
Authentication: &gittypes.GitAuthentication{
Username: username,
Password: password,
},
},
}
}
func newAuthlessSource(url string) *portainer.Source {
return &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: url},
}
}
func validateUniqueSourceInStore(t *testing.T, store *datastore.Store, url, username, password string, sourceID portainer.SourceID) bool {
t.Helper()
var isUnique bool
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
isUnique, err = ValidateUniqueSource(tx, url, username, password, sourceID)
return err
}))
return isUnique
}
func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
}
func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
}
func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git"))
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
}
func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
if err := tx.Source().Create(src); err != nil {
return err
}
srcID = src.ID
return nil
}))
require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
}
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
@@ -32,9 +33,9 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ
return httperror.BadRequest("Invalid query parameter: method", err) return httperror.BadRequest("Invalid query parameter: method", err)
} }
tokenData, err := security.RetrieveTokenData(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err) return httperror.InternalServerError("Unable to retrieve info from request context", err)
} }
customTemplate, err := handler.createCustomTemplate(method, r) customTemplate, err := handler.createCustomTemplate(method, r)
@@ -42,16 +43,16 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to create custom template", err) return httperror.InternalServerError("Unable to create custom template", err)
} }
customTemplate.CreatedByUserID = tokenData.ID customTemplate.CreatedByUserID = securityContext.UserID
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return createCustomTemplateTx(tx, customTemplate, tokenData.ID) return createCustomTemplateTx(tx, customTemplate, securityContext)
}) })
return response.TxResponse(w, customTemplate, err) return response.TxResponse(w, customTemplate, err)
} }
func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, userID portainer.UserID) error { func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, sc *security.RestrictedRequestContext) error {
existingTemplates, err := tx.CustomTemplate().ReadAll() existingTemplates, err := tx.CustomTemplate().ReadAll()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
@@ -67,14 +68,16 @@ func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portain
return httperror.InternalServerError("Unable to create custom template", err) return httperror.InternalServerError("Unable to create custom template", err)
} }
resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, userID) resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, sc.UserID)
if err := tx.ResourceControl().Create(resourceControl); err != nil { if err := tx.ResourceControl().Create(resourceControl); err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err) return httperror.InternalServerError("Unable to persist resource control inside the database", err)
} }
customTemplate.ResourceControl = resourceControl customTemplate.ResourceControl = resourceControl
populateGitConfig(tx, customTemplate)
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
return nil return nil
} }
@@ -282,6 +285,11 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
return nil, err return nil, err
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier()
customTemplate := &portainer.CustomTemplate{ customTemplate := &portainer.CustomTemplate{
ID: portainer.CustomTemplateID(customTemplateID), ID: portainer.CustomTemplateID(customTemplateID),
@@ -302,7 +310,9 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
projectPath := getProjectPath() projectPath := getProjectPath()
customTemplate.ProjectPath = projectPath customTemplate.ProjectPath = projectPath
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{ userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{
SourceID: payload.SourceID, SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName, ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository, ConfigFilePath: payload.ComposeFilePathInRepository,
@@ -327,7 +337,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
sourceID := payload.SourceID sourceID := payload.SourceID
if sourceID == 0 { if sourceID == 0 {
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{ src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL), Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
@@ -30,7 +30,14 @@ func createTemplateRequest(t *testing.T, method string, payload any, userID port
r.Header.Set("Content-Type", "application/json") r.Header.Set("Content-Type", "application/json")
r = mux.SetURLVars(r, map[string]string{"method": method}) r = mux.SetURLVars(r, map[string]string{"method": method})
return r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role})
r = r.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: role == portainer.AdministratorRole,
User: &portainer.User{ID: userID, Role: role},
})
return r.WithContext(ctx)
} }
func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) { func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) {
@@ -272,7 +279,13 @@ func TestCustomTemplateCreate_FromFileUpload_Success(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -461,7 +474,13 @@ func TestCustomTemplateCreate_FromFileUpload_MissingTitle(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -497,7 +516,13 @@ func TestCustomTemplateCreate_FromFileUpload_MissingDescription(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -530,7 +555,13 @@ func TestCustomTemplateCreate_FromFileUpload_MissingFile(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -569,7 +600,13 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidType(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -608,7 +645,13 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidPlatform(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -650,7 +693,13 @@ func TestCustomTemplateCreate_FromFileUpload_NoteWithImage(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -689,7 +738,13 @@ func TestCustomTemplateCreate_FromFileUpload_KubernetesIgnoresPlatform(t *testin
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -748,7 +803,13 @@ func TestCustomTemplateCreate_FromFileUpload_Variables(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -804,7 +865,13 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidVariables(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body)
r.Header.Set("Content-Type", writer.FormDataContentType()) r.Header.Set("Content-Type", writer.FormDataContentType())
r = mux.SetURLVars(r, map[string]string{"method": "file"}) r = mux.SetURLVars(r, map[string]string{"method": "file"})
r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})
r = r.WithContext(ctx)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{
UserID: 1,
IsAdmin: true,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateCreate(rr, r) herr := handler.customTemplateCreate(rr, r)
@@ -866,7 +933,7 @@ func TestCustomTemplateCreate_FromRepository_Success(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, stored.Artifact) require.NotNil(t, stored.Artifact)
src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID) src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL) require.Equal(t, "https://github.com/example/repo", src.Git.URL)
@@ -903,7 +970,7 @@ func TestCustomTemplateCreate_FromRepository_DeduplicatesSource(t *testing.T) {
require.Nil(t, herr) require.Nil(t, herr)
err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { err := ds.ViewTx(func(tx dataservices.DataStoreTx) error {
sources, err := tx.Source().ReadAll() sources, err := tx.Source().ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, sources, 1, "two templates with the same URL must share one Source") require.Len(t, sources, 1, "two templates with the same URL must share one Source")
@@ -1052,7 +1119,7 @@ func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T)
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
return nil return nil
@@ -153,7 +153,7 @@ func TestCustomTemplateFile_GitTemplate(t *testing.T) {
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"},
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent)) path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent))
@@ -7,7 +7,10 @@ import (
"sync" "sync"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
@@ -49,10 +52,24 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
file := customTemplate.Artifact.Files[0] file := customTemplate.Artifact.Files[0]
src, err := handler.DataStore.Source().Read(file.SourceID) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var src *portainer.Source
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
src, err = tx.Source().Read(userContext, file.SourceID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve git source for custom template", err) return httperror.InternalServerError("Unable to retrieve git source for custom template", err)
} }
return nil
}); err != nil {
return response.TxErrorResponse(err)
}
if src.Git == nil { if src.Git == nil {
return httperror.InternalServerError("Source has no git configuration", nil) return httperror.InternalServerError("Source has no git configuration", nil)
@@ -176,11 +176,12 @@ func Test_customTemplateGitFetch(t *testing.T) {
src := &portainer.Source{ src := &portainer.Source{
ID: 1, ID: 1,
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Public: true,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
}, },
} }
err = store.Source().Create(src) err = store.Source().Create(adminUserContext, src)
require.NoError(t, err, "error creating source") require.NoError(t, err, "error creating source")
const configFilePath = "test-config-path.txt" const configFilePath = "test-config-path.txt"
@@ -336,31 +337,3 @@ func TestCustomTemplateGitFetch_EmptySourceIDsReturnsBadRequest(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rr.Code) require.Equal(t, http.StatusBadRequest, rr.Code)
} }
func TestCustomTemplateGitFetch_SourceWithNilGitConfigReturnsInternalError(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := store.Source().Create(src)
require.NoError(t, err)
template := &portainer.CustomTemplate{
ID: 1,
Title: "nil-git-config",
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
},
}
err = store.CustomTemplateService.Create(template)
require.NoError(t, err)
h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{})
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}"))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusInternalServerError, rr.Code)
}
@@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
@@ -70,7 +71,8 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
} }
populateGitConfig(tx, customTemplate) userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
return nil return nil
}) })
@@ -174,7 +174,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
TLSSkipVerify: true, TLSSkipVerify: true,
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -194,7 +194,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/custom_templates/10", nil) r := httptest.NewRequest(http.MethodGet, "/custom_templates/10", nil)
r = mux.SetURLVars(r, map[string]string{"id": "10"}) r = mux.SetURLVars(r, map[string]string{"id": "10"})
ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})
r = r.WithContext(ctx) r = r.WithContext(ctx)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
herr := handler.customTemplateInspect(rr, r) herr := handler.customTemplateInspect(rr, r)
@@ -5,6 +5,8 @@ import (
"strconv" "strconv"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/slicesx"
@@ -37,25 +39,28 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
edge := retrieveEdgeParam(r) edge := retrieveEdgeParam(r)
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll() securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var customTemplates []portainer.CustomTemplate
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
customTemplates, err = tx.CustomTemplate().ReadAll()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
} }
resourceControls, err := handler.DataStore.ResourceControl().ReadAll() resourceControls, err := tx.ResourceControl().ReadAll()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve resource controls from the database", err) return httperror.InternalServerError("Unable to retrieve resource controls from the database", err)
} }
customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls) customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !securityContext.IsAdmin { if !securityContext.IsAdmin {
user, err := handler.DataStore.User().Read(securityContext.UserID) user, err := tx.User().Read(securityContext.UserID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve user information from the database", err) return httperror.InternalServerError("Unable to retrieve user information from the database", err)
} }
@@ -73,11 +78,15 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
}) })
} }
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
for i := range customTemplates { for i := range customTemplates {
populateGitConfig(handler.DataStore, &customTemplates[i]) populateGitConfig(tx, userContext, &customTemplates[i])
} }
return response.JSON(w, customTemplates) return nil
})
return response.TxResponse(w, customTemplates, err)
} }
func retrieveEdgeParam(r *http.Request) *bool { func retrieveEdgeParam(r *http.Request) *bool {
@@ -28,7 +28,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) {
TLSSkipVerify: true, TLSSkipVerify: true,
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
@@ -48,7 +48,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) {
})) }))
r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil) r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})) r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, r) handler.ServeHTTP(rr, r)
@@ -97,7 +97,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) {
}, },
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{
@@ -111,7 +111,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) {
})) }))
r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil) r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil)
r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})) r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, r) handler.ServeHTTP(rr, r)
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
@@ -182,8 +183,10 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.IsComposeFormat = payload.IsComposeFormat customTemplate.IsComposeFormat = payload.IsComposeFormat
customTemplate.EdgeTemplate = payload.EdgeTemplate customTemplate.EdgeTemplate = payload.EdgeTemplate
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 || payload.RepositoryURL != "" { if payload.SourceID != 0 || payload.RepositoryURL != "" {
gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{ gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{
SourceID: payload.SourceID, SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName, ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.ComposeFilePathInRepository, ConfigFilePath: payload.ComposeFilePathInRepository,
@@ -231,7 +234,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
sourceID := payload.SourceID sourceID := payload.SourceID
if sourceID == 0 { if sourceID == 0 {
src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{ src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{
Name: gittypes.RepoName(gitConfig.URL), Name: gittypes.RepoName(gitConfig.URL),
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{ Git: &gittypes.RepoConfig{
@@ -271,7 +274,8 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist custom template changes inside the database", err) return httperror.InternalServerError("Unable to persist custom template changes inside the database", err)
} }
populateGitConfig(tx, customTemplate) userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
populateGitConfig(tx, userContext, customTemplate)
return nil return nil
}) })
@@ -27,6 +27,14 @@ func updateTemplateRequest(t *testing.T, templateID string, payload any, ctx *se
r.Header.Set("Content-Type", "application/json") r.Header.Set("Content-Type", "application/json")
r = mux.SetURLVars(r, map[string]string{"id": templateID}) r = mux.SetURLVars(r, map[string]string{"id": templateID})
if ctx.User == nil {
role := portainer.StandardUserRole
if ctx.IsAdmin {
role = portainer.AdministratorRole
}
ctx.User = &portainer.User{ID: ctx.UserID, Role: role}
}
return r.WithContext(security.StoreRestrictedRequestContext(r, ctx)) return r.WithContext(security.StoreRestrictedRequestContext(r, ctx))
} }
@@ -476,7 +484,7 @@ func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) {
URL: "https://github.com/example/repo", URL: "https://github.com/example/repo",
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
return nil return nil
@@ -630,7 +638,7 @@ func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, stored.Artifact) require.NotNil(t, stored.Artifact)
src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID) src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, portainer.SourceTypeGit, src.Type)
require.Equal(t, "https://github.com/example/repo", src.Git.URL) require.Equal(t, "https://github.com/example/repo", src.Git.URL)
@@ -0,0 +1,5 @@
package customtemplates
import "github.com/portainer/portainer/api/dataservices/source"
var adminUserContext = source.InsecureNewAdminContext()
+2 -2
View File
@@ -8,14 +8,14 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
) )
func populateGitConfig(tx dataservices.DataStoreTx, template *portainer.CustomTemplate) { func populateGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, template *portainer.CustomTemplate) {
if template.Artifact == nil || len(template.Artifact.Files) == 0 { if template.Artifact == nil || len(template.Artifact.Files) == 0 {
return return
} }
file := template.Artifact.Files[0] file := template.Artifact.Files[0]
src, err := tx.Source().Read(file.SourceID) src, err := tx.Source().Read(userContext, file.SourceID)
if err != nil || src.Git == nil { if err != nil || src.Git == nil {
return return
} }
+7 -38
View File
@@ -19,7 +19,8 @@ func TestPopulateGitConfig_NilArtifactIsNoOp(t *testing.T) {
template := &portainer.CustomTemplate{ID: 1} template := &portainer.CustomTemplate{ID: 1}
err := store.ViewTx(func(tx dataservices.DataStoreTx) error { err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template)
populateGitConfig(tx, adminUserContext, template)
return nil return nil
}) })
@@ -40,39 +41,7 @@ func TestPopulateGitConfig_EmptySourceIDsIsNoOp(t *testing.T) {
} }
err := store.ViewTx(func(tx dataservices.DataStoreTx) error { err := store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template) populateGitConfig(tx, adminUserContext, template)
return nil
})
require.NoError(t, err)
require.Nil(t, template.GitConfig)
}
func TestPopulateGitConfig_SourceWithNilGitConfigIsNoOp(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return nil
})
require.NoError(t, err)
template := &portainer.CustomTemplate{
ID: 1,
Artifact: &portainer.Artifact{
Files: []portainer.ArtifactFile{{SourceID: srcID}},
},
}
err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template)
return nil return nil
}) })
@@ -94,7 +63,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) {
TLSSkipVerify: true, TLSSkipVerify: true,
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -115,7 +84,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) {
} }
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template) populateGitConfig(tx, adminUserContext, template)
return nil return nil
}) })
@@ -145,7 +114,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) {
}, },
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -161,7 +130,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) {
} }
err = store.ViewTx(func(tx dataservices.DataStoreTx) error { err = store.ViewTx(func(tx dataservices.DataStoreTx) error {
populateGitConfig(tx, template) populateGitConfig(tx, adminUserContext, template)
return nil return nil
}) })
@@ -7,12 +7,15 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/edge" "github.com/portainer/portainer/pkg/edge"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/libhttp/ssrf"
"github.com/portainer/portainer/pkg/validate" "github.com/portainer/portainer/pkg/validate"
@@ -124,7 +127,13 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
return stack, nil return stack, nil
} }
repoConfig, httpErr := sources.ResolveRepoConfig(tx, sources.RepoConfigInput{ securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
repoConfig, httpErr := sources.ResolveRepoConfig(tx, userContext, sources.RepoConfigInput{
SourceID: payload.SourceID, SourceID: payload.SourceID,
ReferenceName: payload.RepositoryReferenceName, ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository, ConfigFilePath: payload.FilePathInRepository,
@@ -7,8 +7,10 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -87,8 +89,14 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht
password := payload.Password password := payload.Password
tlsSkipVerify := payload.TLSSkipVerify tlsSkipVerify := payload.TLSSkipVerify
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 { if payload.SourceID != 0 {
src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, payload.SourceID) src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, userContext, payload.SourceID)
if httpErr != nil { if httpErr != nil {
return httpErr return httpErr
} }
+24 -18
View File
@@ -7,8 +7,9 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -21,8 +22,16 @@ type GitAuthenticationPayload struct {
Password string `json:"password"` Password string `json:"password"`
} }
type SourceAccessControlPayload struct {
Public bool `json:"public" example:"true"`
AdministratorsOnly bool `json:"administratorsOnly" example:"true"`
UserAccesses []portainer.UserID `json:"userAccesses"`
TeamAccesses []portainer.TeamID `json:"teamAccesses"`
}
// GitSourceCreatePayload holds the parameters for creating a git-backed source // GitSourceCreatePayload holds the parameters for creating a git-backed source
type GitSourceCreatePayload struct { type GitSourceCreatePayload struct {
SourceAccessControlPayload
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url" validate:"required"` URL string `json:"url" validate:"required"`
TLSSkipVerify bool `json:"tlsSkipVerify"` TLSSkipVerify bool `json:"tlsSkipVerify"`
@@ -41,7 +50,7 @@ func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error {
// @id GitOpsSourcesCreateGit // @id GitOpsSourcesCreateGit
// @summary Create a Git source // @summary Create a Git source
// @description Creates a new GitOps source backed by a Git repository. // @description Creates a new GitOps source backed by a Git repository.
// @description **Access policy**: administrator // @description **Access policy**: authenticated
// @tags gitops // @tags gitops
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@@ -61,26 +70,20 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
src, err := BuildGitSource(payload) src, err := BuildGitSource(payload)
if err != nil { if err != nil {
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
} }
username, password := "", ""
if payload.Authentication != nil {
username = payload.Authentication.Username
password = payload.Authentication.Password
}
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if isUnique, err := workflows.ValidateUniqueSource(tx, payload.URL, username, password, 0); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
return err return tx.Source().Create(userContext, src)
} else if !isUnique { }); errors.Is(err, source.ErrDuplicateSource) {
return ErrDuplicateSource
}
return tx.Source().Create(src)
}); errors.Is(err, ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err) return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to create source", err) return httperror.InternalServerError("Unable to create source", err)
@@ -99,8 +102,7 @@ func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) {
return src, nil return src, nil
} }
// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without // BuildBaseGitSource constructs the source skeleton (name, URL, TLS, accesses) without authentication.
// authentication.
func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source { func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
name := payload.Name name := payload.Name
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
@@ -114,6 +116,10 @@ func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source {
URL: payload.URL, URL: payload.URL,
TLSSkipVerify: payload.TLSSkipVerify, TLSSkipVerify: payload.TLSSkipVerify,
}, },
UserAccesses: payload.UserAccesses,
TeamAccesses: payload.TeamAccesses,
Public: payload.Public,
AdministratorsOnly: payload.AdministratorsOnly,
} }
} }
@@ -97,7 +97,7 @@ func TestGitSourceCreate_Success(t *testing.T) {
require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, portainer.SourceTypeGit, src.Type)
require.NotZero(t, src.ID) require.NotZero(t, src.ID)
require.NotNil(t, src.Git) require.NotNil(t, src.Git)
require.Equal(t, "https://github.com/org/repo.git", src.Git.URL) require.Equal(t, "https://github.com/org/repo", src.Git.URL)
} }
func TestGitSourceCreate_SanitizesCredentials(t *testing.T) { func TestGitSourceCreate_SanitizesCredentials(t *testing.T) {
+14 -3
View File
@@ -8,6 +8,8 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors" dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -18,7 +20,7 @@ var ErrSourceInUse = errors.New("source is used by one or more workflows or cust
// @id GitOpsSourcesDelete // @id GitOpsSourcesDelete
// @summary Delete a source // @summary Delete a source
// @description Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template. // @description Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template.
// @description **Access policy**: admin // @description **Access policy**: authenticated
// @tags gitops // @tags gitops
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@@ -36,8 +38,15 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro
return httperror.BadRequest("Invalid source identifier route variable", err) return httperror.BadRequest("Invalid source identifier route variable", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if exists, err := tx.Source().Exists(portainer.SourceID(sourceID)); err != nil {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if exists, err := tx.Source().Exists(userContext, portainer.SourceID(sourceID)); err != nil {
return err return err
} else if !exists { } else if !exists {
return dserrors.ErrObjectNotFound return dserrors.ErrObjectNotFound
@@ -71,11 +80,13 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro
return ErrSourceInUse return ErrSourceInUse
} }
return tx.Source().Delete(portainer.SourceID(sourceID)) return tx.Source().Delete(userContext, portainer.SourceID(sourceID))
}); h.dataStore.IsErrObjectNotFound(err) { }); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err) return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrSourceInUse) { } else if errors.Is(err, ErrSourceInUse) {
return httperror.Conflict("Source is used by one or more workflows or custom templates", err) return httperror.Conflict("Source is used by one or more workflows or custom templates", err)
} else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to delete source", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to delete source", err) return httperror.InternalServerError("Unable to delete source", err)
} }
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -19,8 +20,8 @@ func TestSourceDelete_Success(t *testing.T) {
var srcID portainer.SourceID var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -57,8 +58,8 @@ func TestSourceDelete_InUse(t *testing.T) {
var srcID portainer.SourceID var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -99,8 +100,8 @@ func TestSourceDelete_InUseByCustomTemplate(t *testing.T) {
var srcID portainer.SourceID var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
+46 -3
View File
@@ -1,12 +1,15 @@
package sources package sources
import ( import (
"errors"
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
sourceDS "github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -27,12 +30,19 @@ type AutoUpdateInfo struct {
FetchInterval string `json:"fetchInterval,omitempty"` FetchInterval string `json:"fetchInterval,omitempty"`
} }
type SourceAccess struct {
Public bool `json:"public,omitempty"`
Users []portainer.UserID `json:"users,omitempty"`
Teams []portainer.TeamID `json:"teams,omitempty"`
}
// SourceDetail extends Source with connection settings and linked workflows. // SourceDetail extends Source with connection settings and linked workflows.
type SourceDetail struct { type SourceDetail struct {
Source Source
Connection connectionInfo `json:"connection" validate:"required"` Connection connectionInfo `json:"connection" validate:"required"`
AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"` AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []workflows.Workflow `json:"workflows"` Workflows []workflows.Workflow `json:"workflows"`
Access SourceAccess `json:"access"`
} }
// @id GitOpsSourceGet // @id GitOpsSourceGet
@@ -56,6 +66,11 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
return httperror.BadRequest("Invalid source identifier route variable", err) return httperror.BadRequest("Invalid source identifier route variable", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
sourceID := portainer.SourceID(srcID) sourceID := portainer.SourceID(srcID)
var source *portainer.Source var source *portainer.Source
@@ -64,7 +79,8 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
source, err = tx.Source().Read(sourceID) userContext := sourceDS.NewUserContext(securityContext.User, securityContext.UserMemberships)
source, err = tx.Source().Read(userContext, sourceID)
if err != nil { if err != nil {
return err return err
} }
@@ -75,15 +91,19 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
if h.dataStore.IsErrObjectNotFound(err) { if h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Source not found", err) return httperror.NotFound("Source not found", err)
} else if errors.Is(err, sourceDS.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to retrieve source", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to retrieve source", err) return httperror.InternalServerError("Unable to retrieve source", err)
} }
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs) access := BuildSourceAccess(source)
detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs, access)
return response.JSON(w, detail) return response.JSON(w, detail)
} }
func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail { func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow, access SourceAccess) SourceDetail {
var autoUpdate *AutoUpdateInfo var autoUpdate *AutoUpdateInfo
if len(sourceWfs) > 0 { if len(sourceWfs) > 0 {
autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate) autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
@@ -94,6 +114,29 @@ func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []
Connection: buildConnectionInfo(cfg), Connection: buildConnectionInfo(cfg),
AutoUpdate: autoUpdate, AutoUpdate: autoUpdate,
Workflows: redactWorkflowCredentials(sourceWfs), Workflows: redactWorkflowCredentials(sourceWfs),
Access: access,
}
}
func BuildSourceAccess(source *portainer.Source) SourceAccess {
if source == nil {
return SourceAccess{}
}
if source.AdministratorsOnly {
return SourceAccess{}
}
if source.Public {
return SourceAccess{
Public: true,
}
}
return SourceAccess{
Public: source.Public,
Users: source.UserAccesses,
Teams: source.TeamAccesses,
} }
} }
+7 -5
View File
@@ -42,13 +42,15 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet)
authenticatedRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost)
authenticatedRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut)
authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete)
authenticatedRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost)
adminRouter := h.PathPrefix("/gitops/sources").Subrouter() adminRouter := h.PathPrefix("/gitops/sources").Subrouter()
adminRouter.Use(bouncer.AdminAccess) adminRouter.Use(bouncer.AdminAccess)
adminRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost) adminRouter.Handle("/{id}/access", httperror.LoggerHandler(h.gitSourceUpdateAccess)).Methods(http.MethodPut)
adminRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete)
adminRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost)
return h return h
} }
+20 -25
View File
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
@@ -17,6 +18,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var adminUserContext = source.InsecureNewAdminContext()
// createGitWorkflow creates a Source and Workflow for the given config and // createGitWorkflow creates a Source and Workflow for the given config and
// wires them up by setting stack.WorkflowID before creating the stack. // wires them up by setting stack.WorkflowID before creating the stack.
func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack, cfg *gittypes.RepoConfig) portainer.SourceID { func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack, cfg *gittypes.RepoConfig) portainer.SourceID {
@@ -27,7 +30,7 @@ func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portain
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: cfg, Git: cfg,
} }
require.NoError(t, tx.Source().Create(src)) require.NoError(t, tx.Source().Create(adminUserContext, src))
wf := &portainer.Workflow{ wf := &portainer.Workflow{
Artifacts: []portainer.Artifact{{ Artifacts: []portainer.Artifact{{
@@ -51,13 +54,19 @@ func newTestHandler(t *testing.T, store dataservices.DataStore) *Handler {
return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil) return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil)
} }
func adminRestrictedContext(userID portainer.UserID) *security.RestrictedRequestContext {
return &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: true,
User: &portainer.User{ID: userID, Role: portainer.AdministratorRole},
}
}
func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request { func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil) req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -65,9 +74,7 @@ func buildGetReq(t *testing.T, userID portainer.UserID, id string) *http.Request
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil) req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -100,9 +107,7 @@ func buildCreateReq(t *testing.T, userID portainer.UserID, body []byte) *http.Re
req := httptest.NewRequest(http.MethodPost, "/gitops/sources/git", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/gitops/sources/git", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -111,9 +116,7 @@ func buildUpdateReq(t *testing.T, userID portainer.UserID, id int, body []byte)
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/gitops/sources/%d", id), bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/gitops/sources/%d", id), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -121,9 +124,7 @@ func buildDeleteReq(t *testing.T, userID portainer.UserID, id int) *http.Request
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/gitops/sources/%d", id), nil) req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/gitops/sources/%d", id), nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -131,9 +132,7 @@ func buildSummaryReq(t *testing.T, userID portainer.UserID) *http.Request {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/sources/summary", nil) req := httptest.NewRequest(http.MethodGet, "/gitops/sources/summary", nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -142,9 +141,7 @@ func buildUpdateReqWithRawID(t *testing.T, userID portainer.UserID, id string, b
req := httptest.NewRequest(http.MethodPut, "/gitops/sources/"+id, bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPut, "/gitops/sources/"+id, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
@@ -152,8 +149,6 @@ func buildDeleteReqWithRawID(t *testing.T, userID portainer.UserID, id string) *
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodDelete, "/gitops/sources/"+id, nil) req := httptest.NewRequest(http.MethodDelete, "/gitops/sources/"+id, nil)
req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID}))
req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID)))
UserID: userID, IsAdmin: true,
}))
return req return req
} }
+8 -8
View File
@@ -9,7 +9,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/http/utils/filters" "github.com/portainer/portainer/api/http/utils/filters"
"github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/slicesx"
@@ -56,7 +56,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle
} }
if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" { if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" {
s, err := ceWorkflows.ParseStatus(status) s, err := workflows.ParseStatus(status)
if err != nil { if err != nil {
return httperror.BadRequest("Invalid status parameter", err) return httperror.BadRequest("Invalid status parameter", err)
} }
@@ -111,11 +111,11 @@ func cacheKey(sc *security.RestrictedRequestContext) string {
func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) { func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) {
var allSrcs []portainer.Source var allSrcs []portainer.Source
var stats map[portainer.SourceID]ceWorkflows.SourceStats var stats map[portainer.SourceID]workflows.SourceStats
if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
allSrcs, stats, err = ceWorkflows.FetchSourceStats(tx, h.k8sFactory, sc) allSrcs, stats, err = workflows.FetchSourceStats(tx, h.k8sFactory, sc)
return err return err
}); err != nil { }); err != nil {
return nil, err return nil, err
@@ -123,12 +123,12 @@ func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedReque
result := make([]Source, 0, len(allSrcs)) result := make([]Source, 0, len(allSrcs))
for _, src := range allSrcs { for _, src := range allSrcs {
s, accessible := stats[src.ID] stat, ok := stats[src.ID]
if !accessible && !sc.IsAdmin { if !ok {
continue stat = workflows.SourceStats{}
} }
result = append(result, h.buildSource(ctx, &src, s)) result = append(result, h.buildSource(ctx, &src, stat))
} }
return result, nil return result, nil
+1 -1
View File
@@ -20,7 +20,7 @@ func TestSourcesList_GroupsByURLAndCredentials(t *testing.T) {
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
cfg := gitCfg("https://github.com/org/repo") cfg := gitCfg("https://github.com/org/repo")
src := &portainer.Source{Name: "repo", Type: portainer.SourceTypeGit, Git: cfg} src := &portainer.Source{Name: "repo", Type: portainer.SourceTypeGit, Git: cfg}
require.NoError(t, tx.Source().Create(src)) require.NoError(t, tx.Source().Create(adminUserContext, src))
wfA := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} wfA := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
require.NoError(t, tx.Workflow().Create(wfA)) require.NoError(t, tx.Workflow().Create(wfA))
@@ -8,7 +8,9 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -17,7 +19,7 @@ import (
// @id GitOpsSourcesTestById // @id GitOpsSourcesTestById
// @summary Test the connection of a stored source // @summary Test the connection of a stored source
// @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. // @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration.
// @description **Access policy**: administrator // @description **Access policy**: authenticated
// @tags gitops // @tags gitops
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@@ -37,6 +39,11 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Invalid source identifier route variable", err) return httperror.BadRequest("Invalid source identifier route variable", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var payload GitSourceUpdatePayload var payload GitSourceUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil && !errors.Is(err, io.EOF) { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil && !errors.Is(err, io.EOF) {
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
@@ -44,10 +51,13 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) *
var src *portainer.Source var src *portainer.Source
if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
src, err = tx.Source().Read(portainer.SourceID(sourceID)) userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
src, err = tx.Source().Read(userContext, portainer.SourceID(sourceID))
return err return err
}); h.dataStore.IsErrObjectNotFound(err) { }); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err) return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to retrieve source", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to find source", err) return httperror.InternalServerError("Unable to find source", err)
} }
@@ -75,7 +85,7 @@ type ConnectionTestResult struct {
// @id GitOpsSourcesTest // @id GitOpsSourcesTest
// @summary Test a Git source connection // @summary Test a Git source connection
// @description Tests connectivity for Git connection details that have not been persisted yet. // @description Tests connectivity for Git connection details that have not been persisted yet.
// @description **Access policy**: administrator // @description **Access policy**: authenticated
// @tags gitops // @tags gitops
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@@ -1,6 +1,7 @@
package sources package sources
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -8,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" ceWorkflows "github.com/portainer/portainer/api/gitops/workflows"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
@@ -38,10 +40,9 @@ func TestSourcesSummary_CountsByStatus(t *testing.T) {
t.Parallel() t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
// With nil gitService and nil GitConfig, all sources get StatusUnknown.
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for _, name := range []string{"source-a", "source-b", "source-c"} { for idx, name := range []string{"source-a", "source-b", "source-c"} {
err := tx.Source().Create(&portainer.Source{Name: name, Type: portainer.SourceTypeGit}) err := tx.Source().Create(adminUserContext, &portainer.Source{Name: name, Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: fmt.Sprintf("http://github.com/org/repo%d", idx)}})
require.NoError(t, err) require.NoError(t, err)
} }
@@ -0,0 +1,101 @@
package sources
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type SourceAccessUpdatePayload struct {
Public bool `json:"public"`
Users []portainer.UserID `json:"users,omitempty"`
Teams []portainer.TeamID `json:"teams,omitempty"`
}
// @id GitOpsSourcesUpdateAccess
// @summary Update a GitOps source's access control
// @description Updates the access control settings for an existing GitOps source.
// @description **Access policy**: administrator
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "Source identifier"
// @param body body SourceAccessUpdatePayload true "Source access control"
// @success 200 {object} portainer.Source
// @failure 400 "Invalid request payload"
// @failure 403 "Access denied"
// @failure 404 "Source not found"
// @failure 500 "Server error"
// @router /gitops/sources/{id}/access [put]
func (h *Handler) gitSourceUpdateAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid source identifier route variable", err)
}
var payload SourceAccessUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
sourceID := portainer.SourceID(id)
var src *portainer.Source
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if src, err = tx.Source().Read(userContext, sourceID); err != nil {
return err
}
ApplySourceAccessChanges(src, payload)
return tx.Source().Update(userContext, src.ID, src)
}); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if err != nil {
return httperror.InternalServerError("Unable to update source access", err)
}
return response.JSON(w, src)
}
// Validate implements the portainer.Validatable interface
func (payload *SourceAccessUpdatePayload) Validate(_ *http.Request) error {
return nil
}
// ApplySourceAccessChanges applies the payload access changes to the source in place.
func ApplySourceAccessChanges(src *portainer.Source, payload SourceAccessUpdatePayload) {
src.Public = payload.Public
if payload.Public {
src.AdministratorsOnly = false
src.UserAccesses = []portainer.UserID{}
src.TeamAccesses = []portainer.TeamID{}
} else if len(payload.Users) == 0 && len(payload.Teams) == 0 {
src.AdministratorsOnly = true
src.UserAccesses = []portainer.UserID{}
src.TeamAccesses = []portainer.TeamID{}
} else {
src.AdministratorsOnly = false
src.UserAccesses = payload.Users
src.TeamAccesses = payload.Teams
}
}
+14 -18
View File
@@ -7,8 +7,9 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
@@ -17,7 +18,6 @@ import (
var ( var (
ErrNotGitSource = errors.New("source is not a Git source") ErrNotGitSource = errors.New("source is not a Git source")
ErrDuplicateSource = errors.New("a source with this URL and credentials already exists")
) )
// GitSourceUpdatePayload holds the parameters for creating a git-backed source // GitSourceUpdatePayload holds the parameters for creating a git-backed source
@@ -46,7 +46,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error {
// @id GitOpsSourcesUpdateGit // @id GitOpsSourcesUpdateGit
// @summary Update a Git source // @summary Update a Git source
// @description Updates an existing GitOps source backed by a Git repository. // @description Updates an existing GitOps source backed by a Git repository.
// @description **Access policy**: administrator // @description **Access policy**: authenticated
// @tags gitops // @tags gitops
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
@@ -73,6 +73,11 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid request payload", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
sourceID := portainer.SourceID(id) sourceID := portainer.SourceID(id)
var src *portainer.Source var src *portainer.Source
@@ -80,7 +85,8 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
if src, err = tx.Source().Read(sourceID); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if src, err = tx.Source().Read(userContext, sourceID); err != nil {
return err return err
} }
@@ -88,24 +94,14 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe
return err return err
} }
username, password := "", "" return tx.Source().Update(userContext, src.ID, src)
if src.Git != nil && src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
if isUnique, err := workflows.ValidateUniqueSource(tx, src.Git.URL, username, password, sourceID); err != nil {
return err
} else if !isUnique {
return ErrDuplicateSource
}
return tx.Source().Update(src.ID, src)
}); h.dataStore.IsErrObjectNotFound(err) { }); h.dataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a source with the specified identifier", err) return httperror.NotFound("Unable to find a source with the specified identifier", err)
} else if errors.Is(err, ErrNotGitSource) { } else if errors.Is(err, ErrNotGitSource) {
return httperror.BadRequest("Source is not a Git source", err) return httperror.BadRequest("Source is not a Git source", err)
} else if errors.Is(err, ErrDuplicateSource) { } else if errors.Is(err, source.ErrNotEnoughPermission) {
return httperror.Forbidden("Not enough permissions to update source", err)
} else if errors.Is(err, source.ErrDuplicateSource) {
return httperror.Conflict("A source with this URL and credentials already exists", err) return httperror.Conflict("A source with this URL and credentials already exists", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to update source", err) return httperror.InternalServerError("Unable to update source", err)
@@ -20,8 +20,8 @@ func TestGitSourceUpdate_Success(t *testing.T) {
var srcID portainer.SourceID var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -46,7 +46,7 @@ func TestGitSourceUpdate_Success(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "new-name", src.Name) require.Equal(t, "new-name", src.Name)
require.NotNil(t, src.Git) require.NotNil(t, src.Git)
require.Equal(t, "https://github.com/org/new.git", src.Git.URL) require.Equal(t, "https://github.com/org/new", src.Git.URL)
} }
func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
@@ -66,7 +66,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
}, },
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -89,7 +89,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) {
var stored *portainer.Source var stored *portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
stored, err = tx.Source().Read(srcID) stored, err = tx.Source().Read(adminUserContext, srcID)
return err return err
})) }))
require.NotNil(t, stored.Git) require.NotNil(t, stored.Git)
@@ -115,7 +115,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) {
}, },
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -138,7 +138,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) {
var stored *portainer.Source var stored *portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
stored, err = tx.Source().Read(srcID) stored, err = tx.Source().Read(adminUserContext, srcID)
return err return err
})) }))
require.NotNil(t, stored.Git) require.NotNil(t, stored.Git)
@@ -162,7 +162,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) {
}, },
}, },
} }
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -188,7 +188,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) {
var stored *portainer.Source var stored *portainer.Source
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
stored, err = tx.Source().Read(srcID) stored, err = tx.Source().Read(adminUserContext, srcID)
return err return err
})) }))
require.NotNil(t, stored.Git) require.NotNil(t, stored.Git)
@@ -229,11 +229,11 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) {
URL: "https://github.com/org/existing.git", URL: "https://github.com/org/existing.git",
}, },
} }
err := tx.Source().Create(existing) err := tx.Source().Create(adminUserContext, existing)
require.NoError(t, err) require.NoError(t, err)
src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err = tx.Source().Create(src) err = tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -253,39 +253,14 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) {
require.Equal(t, http.StatusConflict, rr.Code) require.Equal(t, http.StatusConflict, rr.Code)
} }
func TestGitSourceUpdate_NotGitSource(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "helm-source", Type: portainer.SourceTypeHelm}
err := tx.Source().Create(src)
require.NoError(t, err)
srcID = src.ID
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
h := newTestHandler(t, store)
body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")})
require.NoError(t, err)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body))
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestGitSourceUpdate_MalformedJSON(t *testing.T) { func TestGitSourceUpdate_MalformedJSON(t *testing.T) {
t.Parallel() t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
var srcID portainer.SourceID var srcID portainer.SourceID
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit} src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}}
err := tx.Source().Create(src) err := tx.Source().Create(adminUserContext, src)
require.NoError(t, err) require.NoError(t, err)
srcID = src.ID srcID = src.ID
@@ -317,7 +292,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T)
}, },
}, },
} }
if err := tx.Source().Create(existing); err != nil { if err := tx.Source().Create(adminUserContext, existing); err != nil {
return err return err
} }
@@ -326,7 +301,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T)
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"}, Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"},
} }
if err := tx.Source().Create(other); err != nil { if err := tx.Source().Create(adminUserContext, other); err != nil {
return err return err
} }
srcID = other.ID srcID = other.ID
@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows" ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@@ -24,6 +25,7 @@ func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.Use
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, UserID: userID,
IsAdmin: security.IsAdminRole(role), IsAdmin: security.IsAdminRole(role),
User: &portainer.User{ID: userID, Role: role},
}) })
return req.WithContext(ctx) return req.WithContext(ctx)
} }
@@ -48,7 +50,7 @@ func createGitStack(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.
if stack.GitConfig != nil { if stack.GitConfig != nil {
src := &portainer.Source{Git: stack.GitConfig, Type: portainer.SourceTypeGit} src := &portainer.Source{Git: stack.GitConfig, Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(src)) require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack.ID, StackID: stack.ID,
@@ -81,7 +81,7 @@ func TestWorkflowsList_Pagination(t *testing.T) {
createGitStack(t, tx, &portainer.Stack{ createGitStack(t, tx, &portainer.Stack{
ID: portainer.StackID(i), ID: portainer.StackID(i),
Name: fmt.Sprintf("stack-%d", i), Name: fmt.Sprintf("stack-%d", i),
GitConfig: gitConfig("https://github.com/x/y"), GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/y-%d", i)),
}) })
} }
@@ -23,6 +23,7 @@ func buildSummaryReq(t *testing.T, userID portainer.UserID, role portainer.UserR
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID, UserID: userID,
IsAdmin: security.IsAdminRole(role), IsAdmin: security.IsAdminRole(role),
User: &portainer.User{ID: userID, Role: role},
}) })
return req.WithContext(ctx) return req.WithContext(ctx)
} }
@@ -6,6 +6,7 @@ import (
"strings" "strings"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
@@ -52,7 +53,7 @@ func createStackPayloadFromComposeFileContentPayload(name string, fileContent st
} }
} }
func (handler *Handler) checkAndCleanStackDupFromSwarm(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID, stack *portainer.Stack) error { func (handler *Handler) checkAndCleanStackDupFromSwarm(_ http.ResponseWriter, _ *http.Request, _ *portainer.Endpoint, _ portainer.UserID, stack *portainer.Stack) error {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil { if err != nil {
return err return err
@@ -279,15 +280,16 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
} }
} }
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err) return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
return httpErr
}
} }
stackPayload := createStackPayloadFromComposeGitPayload(payload.Name, stackPayload := createStackPayloadFromComposeGitPayload(payload.Name,
@@ -5,8 +5,10 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -234,8 +236,13 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
} }
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 { if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
return httpErr return httpErr
} }
} }
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@@ -218,15 +219,15 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
} }
} }
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil {
return httpErr
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err) return httperror.InternalServerError("Unable to retrieve user info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 {
if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil {
return httpErr
}
} }
stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name, stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name,
+8 -8
View File
@@ -15,8 +15,8 @@ type stackResponse struct {
// loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash) // loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash)
// and the SourceID for the given stack. // and the SourceID for the given stack.
func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) { func loadGitConfigForStack(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) {
src, file, err := workflows.GitSourceAndArtifactForStack(tx, workflowID, stackID) src, file, err := workflows.GitSourceAndArtifactForStack(tx, userContext, workflowID, stackID)
if err != nil || src == nil { if err != nil || src == nil {
return nil, 0, err return nil, 0, err
} }
@@ -27,7 +27,7 @@ func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.Wor
// saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's // saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's
// artifact is repointed to that existing Source (selected by the caller) without modifying any // artifact is repointed to that existing Source (selected by the caller) without modifying any
// Source's git config; otherwise the target Source is derived from cfg.URL. // Source's git config; otherwise the target Source is derived from cfg.URL.
func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { func saveStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error {
matchArtifact := func(a portainer.Artifact) bool { matchArtifact := func(a portainer.Artifact) bool {
return a.StackID == stackID return a.StackID == stackID
} }
@@ -41,16 +41,16 @@ func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.Workfl
}) })
} }
return workflows.SaveWorkflowGitConfig(tx, workflowID, matchArtifact, oldSourceID, cfg) return workflows.SaveWorkflowGitConfig(tx, userContext, workflowID, matchArtifact, oldSourceID, cfg)
} }
// newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId. // newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId.
func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*stackResponse, error) { func newStackResponse(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) (*stackResponse, error) {
if stack.WorkflowID == 0 { if stack.WorkflowID == 0 {
return &stackResponse{Stack: *stack}, nil return &stackResponse{Stack: *stack}, nil
} }
gitConfig, gitSourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) gitConfig, gitSourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -61,12 +61,12 @@ func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*sta
} }
// fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses. // fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses.
func fillStackGitConfig(tx dataservices.DataStoreTx, stack *portainer.Stack) error { func fillStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) error {
if stack.WorkflowID == 0 { if stack.WorkflowID == 0 {
return nil return nil
} }
gitConfig, _, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) gitConfig, _, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return err return err
} }
+8 -2
View File
@@ -7,6 +7,8 @@ import (
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -120,9 +122,13 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
stack.ResourceControl = resourceControl stack.ResourceControl = resourceControl
if err := fillStackGitConfig(handler.DataStore, stack); err != nil { err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
return nil
})
return response.JSON(w, stack) return response.TxResponse(w, stack, err)
} }
+20 -5
View File
@@ -4,6 +4,8 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
@@ -126,16 +128,29 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
resourceControl = authorization.NewPrivateResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, userID) resourceControl = authorization.NewPrivateResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, userID)
} }
err = handler.DataStore.ResourceControl().Create(resourceControl) err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
err = tx.ResourceControl().Create(resourceControl)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to persist resource control inside the database", err) return httperror.InternalServerError("Unable to persist resource control inside the database", err)
} }
stack.ResourceControl = resourceControl stack.ResourceControl = resourceControl
if err := fillStackGitConfig(handler.DataStore, stack); err != nil { user, err := tx.User().Read(userID)
return httperror.InternalServerError("Unable to load git config for stack", err) if err != nil {
return httperror.InternalServerError("Unable to read user", err)
} }
return response.JSON(w, stack) userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user's team memberships", err)
}
userContext := source.NewUserContext(user, userMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err)
}
return nil
})
return response.TxResponse(w, stack, err)
} }
+2 -2
View File
@@ -192,7 +192,7 @@ func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, stack, endpoint) return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, userID, stack, endpoint)
} }
return handler.SwarmStackManager.Remove(ctx, stack, endpoint) return handler.SwarmStackManager.Remove(ctx, stack, endpoint)
@@ -202,7 +202,7 @@ func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(ctx, stack, endpoint) return handler.StackDeployer.UndeployRemoteComposeStack(ctx, userID, stack, endpoint)
} }
return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint) return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint)
+9 -1
View File
@@ -4,6 +4,8 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@@ -96,11 +98,17 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
var gitConfig *gittypes.RepoConfig var gitConfig *gittypes.RepoConfig
if stack.WorkflowID != 0 { if stack.WorkflowID != 0 {
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error var err error
gitConfig, _, err = loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID) userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, _, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
return nil
}); err != nil {
return response.TxErrorResponse(err)
}
} }
if gitStackPendingRedeploy(stack, gitConfig) { if gitStackPendingRedeploy(stack, gitConfig) {
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
@@ -45,7 +46,7 @@ func TestStackFile_GitPendingRedeploy_Returns409(t *testing.T) {
ConfigFilePath: "docker-compose.yml", ConfigFilePath: "docker-compose.yml",
}, },
} }
require.NoError(t, store.Source().Create(src)) require.NoError(t, store.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stackID, StackID: stackID,
+3 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
@@ -91,7 +92,8 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
} }
} }
resp, err := newStackResponse(handler.DataStore, stack) userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
resp, err := newStackResponse(handler.DataStore, userContext, stack)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
+8 -2
View File
@@ -4,6 +4,8 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
@@ -79,13 +81,17 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs) stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs)
} }
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
for i := range stacks { for i := range stacks {
if err := fillStackGitConfig(handler.DataStore, &stacks[i]); err != nil { if err := fillStackGitConfig(tx, userContext, &stacks[i]); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
} }
return nil
})
return response.JSON(w, stacks) return response.TxResponse(w, stacks, err)
} }
// filterStacks refines a collection of Stack instances using specified criteria. // filterStacks refines a collection of Stack instances using specified criteria.
+10 -2
View File
@@ -7,6 +7,8 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@@ -172,11 +174,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
} }
} }
if err := fillStackGitConfig(handler.DataStore, stack); err != nil { err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
return response.JSON(w, stack) return nil
})
return response.TxResponse(w, stack, err)
} }
func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError {
+13 -8
View File
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@@ -136,7 +137,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID stack.AutoUpdate.JobID = jobID
} }
if err := handler.startStack(context.TODO(), stack, endpoint, securityContext); err != nil { if err := handler.startStack(context.TODO(), securityContext.UserID, stack, endpoint, securityContext); err != nil {
stack.Status = portainer.StackStatusError stack.Status = portainer.StackStatusError
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
Status: portainer.StackStatusError, Status: portainer.StackStatusError,
@@ -156,21 +157,25 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.DeploymentStatus = []portainer.StackDeploymentStatus{ stack.DeploymentStatus = []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusActive, Time: time.Now().Unix()}, {Status: portainer.StackStatusActive, Time: time.Now().Unix()},
} }
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return tx.Stack().Update(stack.ID, stack) if err := tx.Stack().Update(stack.ID, stack); err != nil {
}); err != nil {
return httperror.InternalServerError("Unable to update stack status", err) return httperror.InternalServerError("Unable to update stack status", err)
} }
if err := fillStackGitConfig(handler.DataStore, stack); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
return nil
})
return response.JSON(w, stack) return response.TxResponse(w, stack, err)
} }
func (handler *Handler) startStack( func (handler *Handler) startStack(
ctx context.Context, ctx context.Context,
userID portainer.UserID,
stack *portainer.Stack, stack *portainer.Stack,
endpoint *portainer.Endpoint, endpoint *portainer.Endpoint,
securityContext *security.RestrictedRequestContext, securityContext *security.RestrictedRequestContext,
@@ -192,7 +197,7 @@ func (handler *Handler) startStack(
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(ctx, stack, endpoint, filteredRegistries) return handler.StackDeployer.StartRemoteComposeStack(ctx, userID, stack, endpoint, filteredRegistries)
} }
return handler.StackDeployer.DeployComposeStack(ctx, stack, endpoint, filteredRegistries, false, false, false) return handler.StackDeployer.DeployComposeStack(ctx, stack, endpoint, filteredRegistries, false, false, false)
@@ -200,7 +205,7 @@ func (handler *Handler) startStack(
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(ctx, stack, endpoint, filteredRegistries) return handler.StackDeployer.StartRemoteSwarmStack(ctx, userID, stack, endpoint, filteredRegistries)
} }
return handler.StackDeployer.DeploySwarmStack(ctx, stack, endpoint, filteredRegistries, true, true) return handler.StackDeployer.DeploySwarmStack(ctx, stack, endpoint, filteredRegistries, true, true)
+12 -9
View File
@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@@ -108,7 +109,7 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
stack.AutoUpdate.JobID = "" stack.AutoUpdate.JobID = ""
} }
stopErr := handler.stopStack(r.Context(), stack, endpoint) stopErr := handler.stopStack(r.Context(), securityContext.UserID, stack, endpoint)
if stopErr != nil { if stopErr != nil {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stackutils.UpdateStackStatusFromUndeploymentResult(stack, stopErr) stackutils.UpdateStackStatusFromUndeploymentResult(stack, stopErr)
@@ -120,27 +121,29 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
return httperror.InternalServerError("Unable to stop stack", stopErr) return httperror.InternalServerError("Unable to stop stack", stopErr)
} }
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stackutils.UpdateStackStatusFromUndeploymentResult(stack, nil) stackutils.UpdateStackStatusFromUndeploymentResult(stack, nil)
return tx.Stack().Update(stack.ID, stack) if err := tx.Stack().Update(stack.ID, stack); err != nil {
}); err != nil {
return httperror.InternalServerError("Unable to update stack status", err) return httperror.InternalServerError("Unable to update stack status", err)
} }
if err := fillStackGitConfig(handler.DataStore, stack); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
return nil
})
return response.JSON(w, stack) return response.TxResponse(w, stack, err)
} }
func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (handler *Handler) stopStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
switch stack.Type { switch stack.Type {
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(ctx, stack, endpoint) return handler.StackDeployer.StopRemoteComposeStack(ctx, userId, stack, endpoint)
} }
return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint) return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint)
@@ -148,7 +151,7 @@ func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, e
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(ctx, stack, endpoint) return handler.StackDeployer.StopRemoteSwarmStack(ctx, userId, stack, endpoint)
} }
return handler.SwarmStackManager.Remove(ctx, stack, endpoint) return handler.SwarmStackManager.Remove(ctx, stack, endpoint)
@@ -47,6 +47,7 @@ func mockCreateStackRequestWithSecurityContext(method, target string, body io.Re
ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
IsAdmin: true, IsAdmin: true,
UserID: portainer.UserID(1), UserID: portainer.UserID(1),
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}) })
return req.WithContext(ctx) return req.WithContext(ctx)
+3 -1
View File
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@@ -188,7 +189,8 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
deployGate.startDeploy() deployGate.startDeploy()
if err := fillStackGitConfig(tx, stack); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := fillStackGitConfig(tx, userContext, stack); err != nil {
return nil, httperror.InternalServerError("Unable to load git config for stack", err) return nil, httperror.InternalServerError("Unable to load git config for stack", err)
} }
+23 -9
View File
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/gitops/sources"
@@ -87,15 +88,30 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError(msg, errors.New(msg)) return httperror.InternalServerError(msg, errors.New(msg))
} }
gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var gitConfig *gittypes.RepoConfig
var sourceID portainer.SourceID
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
if gitConfig == nil { if gitConfig == nil {
msg := "No Git config in the found stack source" msg := "No Git config in the found stack source"
return httperror.InternalServerError(msg, errors.New(msg)) return httperror.InternalServerError(msg, errors.New(msg))
} }
return nil
}); err != nil {
return response.TxErrorResponse(err)
}
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" && if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil || (stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) { (stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
@@ -126,11 +142,6 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.Forbidden("Permission denied to access environment", err) return httperror.Forbidden("Permission denied to access environment", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
user, err := handler.DataStore.User().Read(securityContext.UserID) user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil { if err != nil {
return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
@@ -193,8 +204,10 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
stack.Option = &portainer.StackOption{Prune: payload.Prune} stack.Option = &portainer.StackOption{Prune: payload.Prune}
} }
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if payload.SourceID != 0 { if payload.SourceID != 0 {
src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID) src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID)
if httpErr != nil { if httpErr != nil {
return httpErr return httpErr
} }
@@ -250,11 +263,12 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
if err := tx.Stack().Update(stack.ID, stack); err != nil { if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err return err
} }
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil {
return err return err
} }
var err error var err error
resp, err = newStackResponse(tx, stack) resp, err = newStackResponse(tx, userContext, stack)
return err return err
}); err != nil { }); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err) return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
@@ -8,7 +8,9 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git" "github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@@ -68,8 +70,17 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid stack identifier route variable", err) return httperror.BadRequest("Invalid stack identifier route variable", err)
} }
stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if handler.DataStore.IsErrObjectNotFound(err) { if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var stack *portainer.Stack
var gitConfig *gittypes.RepoConfig
var sourceID portainer.SourceID
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
stack, err = tx.Stack().Read(portainer.StackID(stackID))
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil { } else if err != nil {
return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
@@ -79,7 +90,8 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow")) return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow"))
} }
gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID) userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
@@ -90,6 +102,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if stack.Status == portainer.StackStatusDeploying { if stack.Status == portainer.StackStatusDeploying {
return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
} }
return nil
}); err != nil {
return response.TxErrorResponse(err)
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 // 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 environment(endpoint) // The EndpointID property is not available for these stacks, this API environment(endpoint)
@@ -113,11 +129,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httperror.Forbidden("Permission denied to access environment", err) return httperror.Forbidden("Permission denied to access environment", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
// Only check resource control when it is a DockerSwarmStack or a DockerComposeStack // Only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
@@ -254,11 +265,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err := tx.Stack().Update(stack.ID, stack); err != nil { if err := tx.Stack().Update(stack.ID, stack); err != nil {
return err return err
} }
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
return err return err
} }
return fillStackGitConfig(tx, stack) return fillStackGitConfig(tx, userContext, stack)
}); err != nil { }); err != nil {
deployGate.abortDeploy() deployGate.abortDeploy()
@@ -8,6 +8,7 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@@ -36,30 +37,23 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
const stack1ID = portainer.StackID(456) const stack1ID = portainer.StackID(456)
const stack2ID = portainer.StackID(457) const stack2ID = portainer.StackID(457)
src1 := &portainer.Source{ sharedSrc := &portainer.Source{
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"}, Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"},
} }
err = store.Source().Create(src1) err = store.Source().Create(source.InsecureNewAdminContext(), sharedSrc)
require.NoError(t, err) require.NoError(t, err)
wf1 := &portainer.Workflow{Artifacts: []portainer.Artifact{{ wf1 := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack1ID, StackID: stack1ID,
Files: []portainer.ArtifactFile{{SourceID: src1.ID}}, Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}},
}}} }}}
err = store.Workflow().Create(wf1) err = store.Workflow().Create(wf1)
require.NoError(t, err) require.NoError(t, err)
src2 := &portainer.Source{
Type: portainer.SourceTypeGit,
Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"},
}
err = store.Source().Create(src2)
require.NoError(t, err)
wf2 := &portainer.Workflow{Artifacts: []portainer.Artifact{{ wf2 := &portainer.Workflow{Artifacts: []portainer.Artifact{{
StackID: stack2ID, StackID: stack2ID,
Files: []portainer.ArtifactFile{{SourceID: src2.ID}}, Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}},
}}} }}}
err = store.Workflow().Create(wf2) err = store.Workflow().Create(wf2)
require.NoError(t, err) require.NoError(t, err)
@@ -99,7 +93,11 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID)) url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID))
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload)) req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload))
rrc := &security.RestrictedRequestContext{} rrc := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
User: &portainer.User{ID: 1, Role: portainer.AdministratorRole},
}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
@@ -54,8 +55,15 @@ func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error
} }
func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
if stack.WorkflowID != 0 { if stack.WorkflowID != 0 {
gitConfig, sourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) gitConfig, sourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to load git config for stack", err) return httperror.InternalServerError("Unable to load git config for stack", err)
} }
@@ -111,7 +119,7 @@ func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *ht
stack.AutoUpdate.JobID = jobID stack.AutoUpdate.JobID = jobID
} }
if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil {
return httperror.InternalServerError("Unable to update source git config", err) return httperror.InternalServerError("Unable to update source git config", err)
} }
+11 -3
View File
@@ -58,6 +58,7 @@ type (
IsTeamLeader bool IsTeamLeader bool
UserID portainer.UserID UserID portainer.UserID
UserMemberships []portainer.TeamMembership UserMemberships []portainer.TeamMembership
User *portainer.User
} }
// tokenLookup looks up a token in the request // tokenLookup looks up a token in the request
@@ -274,7 +275,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
return return
} }
requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) requestContext, err := newRestrictedContextRequest(bouncer.dataStore, tokenData.ID, tokenData.Role)
if err != nil { if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err) httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err)
return return
@@ -535,15 +536,21 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
}) })
} }
func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) { func newRestrictedContextRequest(tx dataservices.DataStoreTx, userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) {
user, err := tx.User().Read(userID)
if err != nil {
return nil, err
}
if userRole == portainer.AdministratorRole { if userRole == portainer.AdministratorRole {
return &RestrictedRequestContext{ return &RestrictedRequestContext{
IsAdmin: true, IsAdmin: true,
UserID: userID, UserID: userID,
User: user,
}, nil }, nil
} }
memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(userID) memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -557,6 +564,7 @@ func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.User
UserID: userID, UserID: userID,
IsTeamLeader: isTeamLeader, IsTeamLeader: isTeamLeader,
UserMemberships: memberships, UserMemberships: memberships,
User: user,
}, nil }, nil
} }
+8 -8
View File
@@ -36,34 +36,34 @@ func (d *TestStackDeployer) DeployKubernetesStack(_ context.Context, stack *port
return nil return nil
} }
func (d *TestStackDeployer) DeployRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { func (d *TestStackDeployer) DeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
return nil return nil
} }
func (d *TestStackDeployer) UndeployRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *TestStackDeployer) UndeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (d *TestStackDeployer) StartRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { func (d *TestStackDeployer) StartRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
return nil return nil
} }
func (d *TestStackDeployer) StopRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *TestStackDeployer) StopRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (d *TestStackDeployer) DeployRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { func (d *TestStackDeployer) DeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil return nil
} }
func (d *TestStackDeployer) UndeployRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *TestStackDeployer) UndeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (d *TestStackDeployer) StartRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { func (d *TestStackDeployer) StartRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
return nil return nil
} }
func (d *TestStackDeployer) StopRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *TestStackDeployer) StopRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
+6
View File
@@ -1328,6 +1328,12 @@ type (
Git *gittypes.RepoConfig `json:"git,omitempty"` Git *gittypes.RepoConfig `json:"git,omitempty"`
Registry *Registry `json:"registry,omitempty"` Registry *Registry `json:"registry,omitempty"`
Helm *HelmConfig `json:"helm,omitempty"` Helm *HelmConfig `json:"helm,omitempty"`
Public bool `json:"public"`
AdministratorsOnly bool `json:"administratorsOnly"`
UserAccesses []UserID `json:"userAccesses"`
TeamAccesses []TeamID `json:"teamAccesses"`
OwnerID UserID `json:"ownerID,omitempty"`
} }
// SourceID represents a source identifier // SourceID represents a source identifier
+14 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/agent" "github.com/portainer/portainer/api/agent"
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
@@ -121,7 +122,17 @@ func redeployWhenChangedSecondStage(
user *portainer.User, user *portainer.User,
endpoint *portainer.Endpoint, endpoint *portainer.Endpoint,
) error { ) error {
gitSrc, file, err := workflows.GitSourceAndArtifactForStack(datastore, stack.WorkflowID, stack.ID) var teamMemberships []portainer.TeamMembership
if err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
teamMemberships, err = tx.TeamMembership().TeamMembershipsByUserID(user.ID)
return err
}); err != nil {
return err
}
userContext := source.NewUserContext(user, teamMemberships)
gitSrc, file, err := workflows.GitSourceAndArtifactForStack(datastore, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return errors.WithMessagef(err, "failed to load git config for stack %v", stack.ID) return errors.WithMessagef(err, "failed to load git config for stack %v", stack.ID)
} }
@@ -184,7 +195,7 @@ func redeployWhenChangedSecondStage(
switch stack.Type { switch stack.Type {
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(ctx, stack, endpoint, registries, true, true, false) err = deployer.DeployRemoteComposeStack(ctx, user.ID, stack, endpoint, registries, true, true, false)
} else { } else {
err = deployer.DeployComposeStack(ctx, stack, endpoint, registries, true, true, false) err = deployer.DeployComposeStack(ctx, stack, endpoint, registries, true, true, false)
} }
@@ -194,7 +205,7 @@ func redeployWhenChangedSecondStage(
} }
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
if stackutils.IsRelativePathStack(stack) { if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(ctx, stack, endpoint, registries, true, true) err = deployer.DeployRemoteSwarmStack(ctx, user.ID, stack, endpoint, registries, true, true)
} else { } else {
err = deployer.DeploySwarmStack(ctx, stack, endpoint, registries, true, true) err = deployer.DeploySwarmStack(ctx, stack, endpoint, registries, true, true)
} }
+17 -14
View File
@@ -13,6 +13,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
@@ -77,6 +78,8 @@ L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b
vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg== vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg==
-----END RSA PRIVATE KEY-----` -----END RSA PRIVATE KEY-----`
var adminUserContext = source.InsecureNewAdminContext()
type noopDeployer struct{} type noopDeployer struct{}
// without unpacker // without unpacker
@@ -97,28 +100,28 @@ func (s noopDeployer) DeployKubernetesStack(_ context.Context, stack *portainer.
} }
// with unpacker // with unpacker
func (s noopDeployer) DeployRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { func (s noopDeployer) DeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
return nil return nil
} }
func (s noopDeployer) UndeployRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (s noopDeployer) UndeployRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (s noopDeployer) StartRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { func (s noopDeployer) StartRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
return nil return nil
} }
func (s noopDeployer) StopRemoteComposeStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (s noopDeployer) StopRemoteComposeStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (s noopDeployer) DeployRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { func (s noopDeployer) DeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil return nil
} }
func (s noopDeployer) UndeployRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (s noopDeployer) UndeployRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (s noopDeployer) StartRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { func (s noopDeployer) StartRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
return nil return nil
} }
func (s noopDeployer) StopRemoteSwarmStack(_ context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (s noopDeployer) StopRemoteSwarmStack(_ context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil return nil
} }
@@ -191,7 +194,7 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
admin := &portainer.User{ID: 1, Username: "admin"} admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
err := store.User().Create(admin) err := store.User().Create(admin)
require.NoError(t, err, "error creating an admin") require.NoError(t, err, "error creating an admin")
@@ -206,7 +209,7 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
ConfigHash: "oldHash", ConfigHash: "oldHash",
}, },
} }
err = store.Source().Create(src) err = store.Source().Create(adminUserContext, src)
require.NoError(t, err, "failed to create source") require.NoError(t, err, "failed to create source")
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
@@ -231,7 +234,7 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
cloneErr := errors.New("failed to clone") cloneErr := errors.New("failed to clone")
_, store := datastore.MustNewTestStore(t, false, true) _, store := datastore.MustNewTestStore(t, false, true)
admin := &portainer.User{ID: 1, Username: "admin"} admin := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
err := store.User().Create(admin) err := store.User().Create(admin)
require.NoError(t, err, "error creating an admin") require.NoError(t, err, "error creating an admin")
@@ -253,7 +256,7 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
ConfigHash: "oldHash", ConfigHash: "oldHash",
}, },
} }
err = store.Source().Create(src) err = store.Source().Create(adminUserContext, src)
require.NoError(t, err, "failed to create source") require.NoError(t, err, "failed to create source")
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
@@ -296,7 +299,7 @@ func setupRedeployStore(t *testing.T, stackType portainer.StackType) (dataservic
ConfigHash: "oldHash", ConfigHash: "oldHash",
}, },
} }
err = store.Source().Create(src) err = store.Source().Create(adminUserContext, src)
require.NoError(t, err, "failed to create source") require.NoError(t, err, "failed to create source")
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}}
@@ -359,7 +362,7 @@ func Test_getUserRegistries(t *testing.T) {
err = store.User().Create(user) err = store.User().Create(user)
require.NoError(t, err, "error creating a user") require.NoError(t, err, "error creating a user")
team := portainer.Team{ID: 1, Name: "team"} team := portainer.Team{ID: 1}
err = store.TeamMembership().Create(&portainer.TeamMembership{ err = store.TeamMembership().Create(&portainer.TeamMembership{
ID: 1, ID: 1,
+71 -19
View File
@@ -10,6 +10,8 @@ import (
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/logs" "github.com/portainer/portainer/api/logs"
@@ -35,20 +37,21 @@ const (
type RemoteStackDeployer interface { type RemoteStackDeployer interface {
// compose // compose
DeployRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, forcePullImage bool, forceRecreate bool) error DeployRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, forcePullImage bool, forceRecreate bool) error
UndeployRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error UndeployRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error
StartRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error StartRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
StopRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error StopRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error
// swarm // swarm
DeployRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error DeployRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error
UndeployRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error UndeployRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error
StartRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error StartRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
StopRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error StopRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error
} }
// Deploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container // Deploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) DeployRemoteComposeStack( func (d *stackDeployer) DeployRemoteComposeStack(
ctx context.Context, ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack, stack *portainer.Stack,
endpoint *portainer.Endpoint, endpoint *portainer.Endpoint,
registries []portainer.Registry, registries []portainer.Registry,
@@ -70,6 +73,7 @@ func (d *stackDeployer) DeployRemoteComposeStack(
return d.remoteStack( return d.remoteStack(
ctx, ctx,
userId,
stack, stack,
endpoint, endpoint,
OperationDeploy, OperationDeploy,
@@ -82,22 +86,29 @@ func (d *stackDeployer) DeployRemoteComposeStack(
} }
// Undeploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container // Undeploy a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) UndeployRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *stackDeployer) UndeployRemoteComposeStack(
ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
) error {
d.lock.Lock() d.lock.Lock()
defer d.lock.Unlock() defer d.lock.Unlock()
return d.remoteStack(ctx, stack, endpoint, OperationUndeploy, unpackerCmdBuilderOptions{}) return d.remoteStack(ctx, userId, stack, endpoint, OperationUndeploy, unpackerCmdBuilderOptions{})
} }
// Start a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container // Start a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) StartRemoteComposeStack( func (d *stackDeployer) StartRemoteComposeStack(
ctx context.Context, ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack, stack *portainer.Stack,
endpoint *portainer.Endpoint, endpoint *portainer.Endpoint,
registries []portainer.Registry, registries []portainer.Registry,
) error { ) error {
return d.remoteStack( return d.remoteStack(
ctx, ctx,
userId,
stack, stack,
endpoint, endpoint,
OperationComposeStart, OperationComposeStart,
@@ -108,13 +119,19 @@ func (d *stackDeployer) StartRemoteComposeStack(
} }
// Stop a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container // Stop a compose stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) StopRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *stackDeployer) StopRemoteComposeStack(
return d.remoteStack(ctx, stack, endpoint, OperationComposeStop, unpackerCmdBuilderOptions{}) ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
) error {
return d.remoteStack(ctx, userId, stack, endpoint, OperationComposeStop, unpackerCmdBuilderOptions{})
} }
// Deploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container // Deploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) DeployRemoteSwarmStack( func (d *stackDeployer) DeployRemoteSwarmStack(
ctx context.Context, ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack, stack *portainer.Stack,
endpoint *portainer.Endpoint, endpoint *portainer.Endpoint,
registries []portainer.Registry, registries []portainer.Registry,
@@ -124,7 +141,7 @@ func (d *stackDeployer) DeployRemoteSwarmStack(
d.lock.Lock() d.lock.Lock()
defer d.lock.Unlock() defer d.lock.Unlock()
return d.remoteStack(ctx, stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{ return d.remoteStack(ctx, userId, stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{
pullImage: pullImage, pullImage: pullImage,
prune: prune, prune: prune,
forceRecreate: stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate, forceRecreate: stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate,
@@ -133,22 +150,29 @@ func (d *stackDeployer) DeployRemoteSwarmStack(
} }
// Undeploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container // Undeploy a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) UndeployRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *stackDeployer) UndeployRemoteSwarmStack(
ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
) error {
d.lock.Lock() d.lock.Lock()
defer d.lock.Unlock() defer d.lock.Unlock()
return d.remoteStack(ctx, stack, endpoint, OperationSwarmUndeploy, unpackerCmdBuilderOptions{}) return d.remoteStack(ctx, userId, stack, endpoint, OperationSwarmUndeploy, unpackerCmdBuilderOptions{})
} }
// Start a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container // Start a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) StartRemoteSwarmStack( func (d *stackDeployer) StartRemoteSwarmStack(
ctx context.Context, ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack, stack *portainer.Stack,
endpoint *portainer.Endpoint, endpoint *portainer.Endpoint,
registries []portainer.Registry, registries []portainer.Registry,
) error { ) error {
return d.remoteStack( return d.remoteStack(
ctx, ctx,
userId,
stack, stack,
endpoint, endpoint,
OperationSwarmStart, OperationSwarmStart,
@@ -157,8 +181,20 @@ func (d *stackDeployer) StartRemoteSwarmStack(
} }
// Stop a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container // Stop a swarm stack on remote environment using a https://github.com/portainer/compose-unpacker container
func (d *stackDeployer) StopRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (d *stackDeployer) StopRemoteSwarmStack(
return d.remoteStack(ctx, stack, endpoint, OperationSwarmStop, unpackerCmdBuilderOptions{}) ctx context.Context,
userId portainer.UserID,
stack *portainer.Stack,
endpoint *portainer.Endpoint,
) error {
return d.remoteStack(
ctx,
userId,
stack,
endpoint,
OperationSwarmStop,
unpackerCmdBuilderOptions{},
)
} }
// Does all the heavy lifting: // Does all the heavy lifting:
@@ -167,9 +203,21 @@ func (d *stackDeployer) StopRemoteSwarmStack(ctx context.Context, stack *portain
// * deploy compose-unpacker container // * deploy compose-unpacker container
// * wait for deployment to end // * wait for deployment to end
// * gather deployment logs and bubble them up // * gather deployment logs and bubble them up
func (d *stackDeployer) remoteStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, operation StackRemoteOperation, opts unpackerCmdBuilderOptions) error { func (d *stackDeployer) remoteStack(ctx context.Context, userID portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, operation StackRemoteOperation, opts unpackerCmdBuilderOptions) error {
if stack.WorkflowID != 0 && opts.gitConfig == nil { if stack.WorkflowID != 0 && opts.gitConfig == nil {
src, file, err := workflows.GitSourceAndArtifactForStack(d.dataStore, stack.WorkflowID, stack.ID) if err := d.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
user, err := tx.User().Read(userID)
if err != nil {
return err
}
memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return err
}
userContext := source.NewUserContext(user, memberships)
src, file, err := workflows.GitSourceAndArtifactForStack(tx, userContext, stack.WorkflowID, stack.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to load git config for remote stack") return errors.Wrap(err, "failed to load git config for remote stack")
} }
@@ -177,6 +225,10 @@ func (d *stackDeployer) remoteStack(ctx context.Context, stack *portainer.Stack,
if src != nil { if src != nil {
opts.gitConfig = workflows.MergeSourceAndFile(src, file) opts.gitConfig = workflows.MergeSourceAndFile(src, file)
} }
return nil
}); err != nil {
return err
}
} }
cli, err := d.createDockerClient(ctx, endpoint) cli, err := d.createDockerClient(ctx, endpoint)
@@ -76,7 +76,7 @@ func (config *ComposeStackDeploymentConfig) Deploy(ctx context.Context) error {
} }
if stackutils.IsRelativePathStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate) return config.StackDeployer.DeployRemoteComposeStack(ctx, config.user.ID, config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate)
} }
return config.StackDeployer.DeployComposeStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate) return config.StackDeployer.DeployComposeStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate)
@@ -89,7 +89,7 @@ func (config *ComposeStackDeploymentConfig) Undeploy(ctx context.Context) error
} }
if stackutils.IsRelativePathStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.UndeployRemoteComposeStack(ctx, config.stack, config.endpoint) return config.StackDeployer.UndeployRemoteComposeStack(ctx, config.user.ID, config.stack, config.endpoint)
} }
return config.StackDeployer.UndeployComposeStack(ctx, config.stack, config.endpoint) return config.StackDeployer.UndeployComposeStack(ctx, config.stack, config.endpoint)
} }
@@ -76,7 +76,7 @@ func (config *SwarmStackDeploymentConfig) Deploy(ctx context.Context) error {
} }
if stackutils.IsRelativePathStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteSwarmStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.pullImage) return config.StackDeployer.DeployRemoteSwarmStack(ctx, config.user.ID, config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
} }
return config.StackDeployer.DeploySwarmStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.pullImage) return config.StackDeployer.DeploySwarmStack(ctx, config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
+21 -2
View File
@@ -7,12 +7,14 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/libhttp/ssrf"
) )
@@ -30,11 +32,28 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo
return err return err
} }
var userContext *dataservices.SourceServiceUserContext
if err := b.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
user, err := tx.User().Read(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user", err)
}
memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to read user team memberships", err)
}
userContext = source.NewUserContext(user, memberships)
return nil
}); err != nil {
return err
}
var repoConfig gittypes.RepoConfig var repoConfig gittypes.RepoConfig
var sourceID portainer.SourceID var sourceID portainer.SourceID
if payload.SourceID != 0 { if payload.SourceID != 0 {
src, err := b.dataStore.Source().Read(payload.SourceID) src, err := b.dataStore.Source().Read(userContext, payload.SourceID)
if err != nil { if err != nil {
return fmt.Errorf("failed to read source: %w", err) return fmt.Errorf("failed to read source: %w", err)
} }
@@ -105,7 +124,7 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo
} else { } else {
repoConfig.URL = gittypes.SanitizeURL(repoConfig.URL) repoConfig.URL = gittypes.SanitizeURL(repoConfig.URL)
src, err := workflows.FindOrCreateGitSource(tx, &portainer.Source{ src, err := workflows.FindOrCreateGitSource(tx, userContext, &portainer.Source{
Name: gittypes.RepoName(repoConfig.URL), Name: gittypes.RepoName(repoConfig.URL),
Type: portainer.SourceTypeGit, Type: portainer.SourceTypeGit,
Git: &repoConfig, Git: &repoConfig,
@@ -5,6 +5,7 @@ import (
"testing" "testing"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/gitops/workflows"
@@ -13,6 +14,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var adminUserContext = source.InsecureNewAdminContext()
// stubFileService satisfies portainer.FileService for git builder tests. // stubFileService satisfies portainer.FileService for git builder tests.
type stubFileService struct { type stubFileService struct {
portainer.FileService portainer.FileService
@@ -25,7 +28,7 @@ func (s *stubFileService) GetStackProjectPath(stackIdentifier string) string {
func newGitMethodBuilder(t *testing.T, commitHash string) *GitMethodStackBuilder { func newGitMethodBuilder(t *testing.T, commitHash string) *GitMethodStackBuilder {
t.Helper() t.Helper()
_, store := datastore.MustNewTestStore(t, false, false) _, store := datastore.MustNewTestStore(t, false, false)
require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "testuser"})) require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "testuser", Role: portainer.AdministratorRole}))
return &GitMethodStackBuilder{ return &GitMethodStackBuilder{
StackBuilder: StackBuilder{ StackBuilder: StackBuilder{
stack: &portainer.Stack{}, stack: &portainer.Stack{},
@@ -52,7 +55,7 @@ func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing.
}, },
}, },
} }
require.NoError(t, builder.dataStore.Source().Create(src)) require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src))
payload := &StackPayload{ payload := &StackPayload{
RepositoryConfigPayload: RepositoryConfigPayload{ RepositoryConfigPayload: RepositoryConfigPayload{
@@ -69,12 +72,12 @@ func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing.
assert.Equal(t, src.ID, referencedSourceID) assert.Equal(t, src.ID, referencedSourceID)
// Only one Source exists — no duplicate was created. // Only one Source exists — no duplicate was created.
allSources, err := builder.dataStore.Source().ReadAll() allSources, err := builder.dataStore.Source().ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, allSources, 1) assert.Len(t, allSources, 1)
// The merged git config picks up the Source URL/auth. // The merged git config picks up the Source URL/auth.
readSrc, artifact, err := workflows.GitSourceAndArtifactForStack(builder.dataStore, builder.stack.WorkflowID, builder.stack.ID) readSrc, artifact, err := workflows.GitSourceAndArtifactForStack(builder.dataStore, adminUserContext, builder.stack.WorkflowID, builder.stack.ID)
require.NoError(t, err) require.NoError(t, err)
merged := workflows.MergeSourceAndFile(readSrc, artifact) merged := workflows.MergeSourceAndFile(readSrc, artifact)
assert.Equal(t, "https://github.com/org/private-repo", merged.URL) assert.Equal(t, "https://github.com/org/private-repo", merged.URL)
@@ -114,7 +117,7 @@ func TestGitMethodStackBuilder_WithoutSourceID_InlinePathStillWorks(t *testing.T
require.NoError(t, err) require.NoError(t, err)
// A Source was created via the inline path. // A Source was created via the inline path.
allSources, err := builder.dataStore.Source().ReadAll() allSources, err := builder.dataStore.Source().ReadAll(adminUserContext)
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, allSources, 1) assert.Len(t, allSources, 1)
assert.Equal(t, "https://github.com/org/public-repo", allSources[0].Git.URL) assert.Equal(t, "https://github.com/org/public-repo", allSources[0].Git.URL)
-3
View File
@@ -329,9 +329,6 @@ angular
component: 'sourcesListView', component: 'sourcesListView',
}, },
}, },
data: {
access: AccessHeaders.Admin,
},
}; };
var gitopsSourceDetail = { var gitopsSourceDetail = {
@@ -21,6 +21,7 @@ export const accessControlModule = angular
'resourceId', 'resourceId',
'resourceType', 'resourceType',
'environmentId', 'environmentId',
'resourceName',
]) ])
) )
.component( .component(
@@ -32,6 +33,7 @@ export const accessControlModule = angular
'onChange', 'onChange',
'value', 'value',
'teams', 'teams',
'resourceName',
]) ])
) )
.component( .component(
@@ -26,6 +26,7 @@ interface Props {
environmentId: EnvironmentId; environmentId: EnvironmentId;
disableOwnershipChange?: boolean; disableOwnershipChange?: boolean;
onUpdateSuccess(): Promise<void>; onUpdateSuccess(): Promise<void>;
resourceName?: string;
} }
export function AccessControlPanel({ export function AccessControlPanel({
@@ -35,6 +36,7 @@ export function AccessControlPanel({
resourceId, resourceId,
environmentId, environmentId,
onUpdateSuccess, onUpdateSuccess,
resourceName = 'resource',
}: Props) { }: Props) {
const [isEditMode, toggleEditMode] = useReducer((state) => !state, false); const [isEditMode, toggleEditMode] = useReducer((state) => !state, false);
const isAdminQuery = useIsEdgeAdmin(); const isAdminQuery = useIsEdgeAdmin();
@@ -62,6 +64,7 @@ export function AccessControlPanel({
<TableContainer> <TableContainer>
<TableTitle label="Access control" icon={Eye} /> <TableTitle label="Access control" icon={Eye} />
<AccessControlPanelDetails <AccessControlPanelDetails
resourceName={resourceName}
resourceType={resourceType} resourceType={resourceType}
resourceControl={resourceControl} resourceControl={resourceControl}
isAuthorisedToFetchUsers={isAdmin || isTeamLeader} isAuthorisedToFetchUsers={isAdmin || isTeamLeader}
@@ -24,14 +24,16 @@ import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
interface Props { interface Props {
resourceControl?: ResourceControlViewModel; resourceControl?: ResourceControlViewModel;
resourceType: ResourceControlType; resourceType?: ResourceControlType;
isAuthorisedToFetchUsers?: boolean; isAuthorisedToFetchUsers?: boolean;
resourceName?: string;
} }
export function AccessControlPanelDetails({ export function AccessControlPanelDetails({
resourceControl, resourceControl,
resourceType, resourceType,
isAuthorisedToFetchUsers = false, isAuthorisedToFetchUsers = false,
resourceName = 'resource',
}: Props) { }: Props) {
const inheritanceMessage = getInheritanceMessage( const inheritanceMessage = getInheritanceMessage(
resourceType, resourceType,
@@ -81,7 +83,7 @@ export function AccessControlPanelDetails({
aria-label="ownership-icon" aria-label="ownership-icon"
/> />
<span aria-label="ownership">{ownership}</span> <span aria-label="ownership">{ownership}</span>
<Tooltip message={getOwnershipTooltip(ownership)} /> <Tooltip message={getOwnershipTooltip(ownership, resourceName)} />
</td> </td>
</tr> </tr>
{inheritanceMessage} {inheritanceMessage}
@@ -102,22 +104,25 @@ export function AccessControlPanelDetails({
); );
} }
function getOwnershipTooltip(ownership: ResourceControlOwnership) { function getOwnershipTooltip(
ownership: ResourceControlOwnership,
resourceName: string
) {
switch (ownership) { switch (ownership) {
case ResourceControlOwnership.PRIVATE: case ResourceControlOwnership.PRIVATE:
return 'Management of this resource is restricted to a single user.'; return `Management of this ${resourceName} is restricted to a single user.`;
case ResourceControlOwnership.RESTRICTED: case ResourceControlOwnership.RESTRICTED:
return 'This resource can be managed by a restricted set of users and/or teams.'; return `This ${resourceName} can be managed by a restricted set of users and/or teams.`;
case ResourceControlOwnership.PUBLIC: case ResourceControlOwnership.PUBLIC:
return 'This resource can be managed by any user with access to this environment.'; return `This ${resourceName} can be managed by any user with access to this environment.`;
case ResourceControlOwnership.ADMINISTRATORS: case ResourceControlOwnership.ADMINISTRATORS:
default: default:
return 'This resource can only be managed by administrators.'; return `This ${resourceName} can only be managed by administrators.`;
} }
} }
function getInheritanceMessage( function getInheritanceMessage(
resourceType: ResourceControlType, resourceType: ResourceControlType | undefined,
resourceControl?: ResourceControlViewModel resourceControl?: ResourceControlViewModel
) { ) {
if (!resourceControl || resourceControl.Type === resourceType) { if (!resourceControl || resourceControl.Type === resourceType) {
@@ -12,6 +12,7 @@ export function AccessTypeSelector({
teams, teams,
value, value,
onChange, onChange,
resourceName = 'resource',
}: { }: {
name: string; name: string;
isAdmin: boolean; isAdmin: boolean;
@@ -19,8 +20,9 @@ export function AccessTypeSelector({
isPublicVisible: boolean; isPublicVisible: boolean;
value: ResourceControlOwnership; value: ResourceControlOwnership;
onChange(value: ResourceControlOwnership): void; onChange(value: ResourceControlOwnership): void;
resourceName?: string;
}) { }) {
const options = useOptions(isAdmin, teams, isPublicVisible); const options = useOptions(isAdmin, teams, isPublicVisible, resourceName);
return ( return (
<BoxSelector <BoxSelector
@@ -19,7 +19,8 @@ interface Props {
isPublicVisible?: boolean; isPublicVisible?: boolean;
errors?: FormikErrors<AccessControlFormData>; errors?: FormikErrors<AccessControlFormData>;
formNamespace?: string; formNamespace?: string;
environmentId: EnvironmentId; resourceName?: string;
environmentId?: EnvironmentId;
} }
export function EditDetails({ export function EditDetails({
@@ -28,6 +29,7 @@ export function EditDetails({
isPublicVisible = false, isPublicVisible = false,
errors, errors,
formNamespace, formNamespace,
resourceName = 'resource',
environmentId, environmentId,
}: Props) { }: Props) {
const { user, isPureAdmin } = useCurrentUser(); const { user, isPureAdmin } = useCurrentUser();
@@ -60,6 +62,7 @@ export function EditDetails({
isAdmin={isPureAdmin} isAdmin={isPureAdmin}
isPublicVisible={isPublicVisible} isPublicVisible={isPublicVisible}
teams={teams} teams={teams}
resourceName={resourceName}
/> />
{values.ownership === ResourceControlOwnership.RESTRICTED && ( {values.ownership === ResourceControlOwnership.RESTRICTED && (
@@ -3,7 +3,7 @@ import { useUsers } from '@/portainer/users/queries';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { useIsEdgeAdmin } from '@/react/hooks/useUser'; import { useIsEdgeAdmin } from '@/react/hooks/useUser';
export function useLoadState(environmentId: EnvironmentId) { export function useLoadState(environmentId?: EnvironmentId) {
const isAdminQuery = useIsEdgeAdmin(); const isAdminQuery = useIsEdgeAdmin();
const teams = useTeams(false, environmentId); const teams = useTeams(false, environmentId);
@@ -10,34 +10,42 @@ import { BadgeIcon } from '@@/BadgeIcon';
import { ResourceControlOwnership } from '../types'; import { ResourceControlOwnership } from '../types';
const publicOption: BoxSelectorOption<ResourceControlOwnership> = { function publicOption(
resourceName: string
): BoxSelectorOption<ResourceControlOwnership> {
return {
value: ResourceControlOwnership.PUBLIC, value: ResourceControlOwnership.PUBLIC,
label: 'Public', label: 'Public',
id: 'access_public', id: 'access_public',
description: description: `I want any user with access to this ${resourceName} to be able to manage this ${resourceName}`,
'I want any user with access to this environment to be able to manage this resource',
icon: <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.PUBLIC)} />, icon: <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.PUBLIC)} />,
}; };
}
export function useOptions( export function useOptions(
isAdmin: boolean, isAdmin: boolean,
teams?: Team[], teams?: Team[],
isPublicVisible = false isPublicVisible = false,
resourceName = 'resource'
) { ) {
const [options, setOptions] = useState< const [options, setOptions] = useState<
Array<BoxSelectorOption<ResourceControlOwnership>> Array<BoxSelectorOption<ResourceControlOwnership>>
>([]); >([]);
useEffect(() => { useEffect(() => {
const options = isAdmin ? adminOptions() : nonAdminOptions(teams); const options = isAdmin
? adminOptions(resourceName)
: nonAdminOptions(teams, resourceName);
setOptions(isPublicVisible ? [...options, publicOption] : options); setOptions(
}, [isAdmin, teams, isPublicVisible]); isPublicVisible ? [...options, publicOption(resourceName)] : options
);
}, [isAdmin, teams, isPublicVisible, resourceName]);
return options; return options;
} }
function adminOptions() { function adminOptions(resourceName: string) {
return [ return [
buildOption( buildOption(
'access_administrators', 'access_administrators',
@@ -45,25 +53,25 @@ function adminOptions() {
icon={ownershipIcon(ResourceControlOwnership.ADMINISTRATORS)} icon={ownershipIcon(ResourceControlOwnership.ADMINISTRATORS)}
/>, />,
'Administrators', 'Administrators',
'I want to restrict the management of this resource to administrators only', `I want to restrict the management of this ${resourceName} to administrators only`,
ResourceControlOwnership.ADMINISTRATORS ResourceControlOwnership.ADMINISTRATORS
), ),
buildOption( buildOption(
'access_restricted', 'access_restricted',
<BadgeIcon icon={ownershipIcon(ResourceControlOwnership.RESTRICTED)} />, <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.RESTRICTED)} />,
'Restricted', 'Restricted',
'I want to restrict the management of this resource to a set of users and/or teams', `I want to restrict the management of this ${resourceName} to a set of users and/or teams`,
ResourceControlOwnership.RESTRICTED ResourceControlOwnership.RESTRICTED
), ),
]; ];
} }
function nonAdminOptions(teams?: Team[]) { function nonAdminOptions(teams?: Team[], resourceName = 'resource') {
return _.compact([ return _.compact([
buildOption( buildOption(
'access_private', 'access_private',
<BadgeIcon icon={ownershipIcon(ResourceControlOwnership.PRIVATE)} />, <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.PRIVATE)} />,
'Private', 'Private',
'I want to restrict this resource to be manageable by myself only', `I want to restrict this ${resourceName} to be manageable by myself only`,
ResourceControlOwnership.PRIVATE ResourceControlOwnership.PRIVATE
), ),
teams && teams &&
@@ -75,12 +83,12 @@ function nonAdminOptions(teams?: Team[]) {
teams.length === 1 ? ( teams.length === 1 ? (
<> <>
I want any member of my team (<b>{teams[0].Name}</b>) to be able to I want any member of my team (<b>{teams[0].Name}</b>) to be able to
manage this resource manage this {resourceName}
</> </>
) : ( ) : (
<> <>
I want to restrict the management of this resource to one or more of I want to restrict the management of this {resourceName} to one or
my teams more of my teams
</> </>
), ),
ResourceControlOwnership.RESTRICTED ResourceControlOwnership.RESTRICTED
+1 -1
View File
@@ -44,7 +44,7 @@ export enum ResourceControlType {
ContainerGroup, ContainerGroup,
} }
enum ResourceAccessLevel { export enum ResourceAccessLevel {
ReadWriteAccessLevel = 1, ReadWriteAccessLevel = 1,
} }
@@ -455,6 +455,9 @@ import type {
GitOpsSourcesTestData, GitOpsSourcesTestData,
GitOpsSourcesTestErrors, GitOpsSourcesTestErrors,
GitOpsSourcesTestResponses, GitOpsSourcesTestResponses,
GitOpsSourcesUpdateAccessData,
GitOpsSourcesUpdateAccessErrors,
GitOpsSourcesUpdateAccessResponses,
GitOpsSourcesUpdateGitData, GitOpsSourcesUpdateGitData,
GitOpsSourcesUpdateGitErrors, GitOpsSourcesUpdateGitErrors,
GitOpsSourcesUpdateGitResponses, GitOpsSourcesUpdateGitResponses,
@@ -2557,7 +2560,7 @@ export const gitOpsSourcesList = <ThrowOnError extends boolean = true>(
* Delete a source * Delete a source
* *
* Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template. * Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template.
* **Access policy**: admin * **Access policy**: authenticated
*/ */
export const gitOpsSourcesDelete = <ThrowOnError extends boolean = true>( export const gitOpsSourcesDelete = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesDeleteData, ThrowOnError> options: Options<GitOpsSourcesDeleteData, ThrowOnError>
@@ -2602,7 +2605,7 @@ export const gitOpsSourceGet = <ThrowOnError extends boolean = true>(
* Update a Git source * Update a Git source
* *
* Updates an existing GitOps source backed by a Git repository. * Updates an existing GitOps source backed by a Git repository.
* **Access policy**: administrator * **Access policy**: authenticated
*/ */
export const gitOpsSourcesUpdateGit = <ThrowOnError extends boolean = true>( export const gitOpsSourcesUpdateGit = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesUpdateGitData, ThrowOnError> options: Options<GitOpsSourcesUpdateGitData, ThrowOnError>
@@ -2625,11 +2628,38 @@ export const gitOpsSourcesUpdateGit = <ThrowOnError extends boolean = true>(
}, },
}); });
/**
* Update a GitOps source's access control
*
* Updates the access control settings for an existing GitOps source.
* **Access policy**: admin
*/
export const gitOpsSourcesUpdateAccess = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesUpdateAccessData, ThrowOnError>
) =>
(options.client ?? client).put<
GitOpsSourcesUpdateAccessResponses,
GitOpsSourcesUpdateAccessErrors,
ThrowOnError
>({
responseType: 'json',
security: [
{ name: 'X-API-KEY', type: 'apiKey' },
{ name: 'Authorization', type: 'apiKey' },
],
url: '/gitops/sources/{id}/access',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
/** /**
* Test the connection of a stored source * Test the connection of a stored source
* *
* Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. * Tests connectivity for a GitOps source, applying optional overrides to the stored configuration.
* **Access policy**: administrator * **Access policy**: authenticated
*/ */
export const gitOpsSourcesTestById = <ThrowOnError extends boolean = true>( export const gitOpsSourcesTestById = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesTestByIdData, ThrowOnError> options: Options<GitOpsSourcesTestByIdData, ThrowOnError>
@@ -2656,7 +2686,7 @@ export const gitOpsSourcesTestById = <ThrowOnError extends boolean = true>(
* Create a Git source * Create a Git source
* *
* Creates a new GitOps source backed by a Git repository. * Creates a new GitOps source backed by a Git repository.
* **Access policy**: administrator * **Access policy**: authenticated
*/ */
export const gitOpsSourcesCreateGit = <ThrowOnError extends boolean = true>( export const gitOpsSourcesCreateGit = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesCreateGitData, ThrowOnError> options: Options<GitOpsSourcesCreateGitData, ThrowOnError>
@@ -2706,7 +2736,7 @@ export const gitOpsSourcesSummary = <ThrowOnError extends boolean = true>(
* Test a Git source connection * Test a Git source connection
* *
* Tests connectivity for Git connection details that have not been persisted yet. * Tests connectivity for Git connection details that have not been persisted yet.
* **Access policy**: administrator * **Access policy**: authenticated
*/ */
export const gitOpsSourcesTest = <ThrowOnError extends boolean = true>( export const gitOpsSourcesTest = <ThrowOnError extends boolean = true>(
options: Options<GitOpsSourcesTestData, ThrowOnError> options: Options<GitOpsSourcesTestData, ThrowOnError>
@@ -4603,6 +4603,7 @@ export type SourcesSourceType =
(typeof SourcesSourceType)[keyof typeof SourcesSourceType]; (typeof SourcesSourceType)[keyof typeof SourcesSourceType];
export type SourcesSourceDetail = { export type SourcesSourceDetail = {
access?: SourcesSourceAccess;
autoUpdate?: SourcesAutoUpdateInfo; autoUpdate?: SourcesAutoUpdateInfo;
connection: SourcesConnectionInfo; connection: SourcesConnectionInfo;
environments?: number; environments?: number;
@@ -4622,6 +4623,18 @@ export type SourcesAutoUpdateInfo = {
mechanism?: string; mechanism?: string;
}; };
export type SourcesSourceAccess = {
public?: boolean;
teams?: Array<number>;
users?: Array<number>;
};
export type SourcesSourceAccessUpdatePayload = {
public?: boolean;
teams?: Array<number>;
users?: Array<number>;
};
export type SourcesSource = { export type SourcesSource = {
environments?: number; environments?: number;
error?: string; error?: string;
@@ -4648,10 +4661,14 @@ export type SourcesGitAuthenticationUpdatePayload = {
}; };
export type SourcesGitSourceCreatePayload = { export type SourcesGitSourceCreatePayload = {
administratorsOnly?: boolean;
authentication?: SourcesGitAuthenticationPayload; authentication?: SourcesGitAuthenticationPayload;
name?: string; name?: string;
public?: boolean;
teamAccesses?: Array<number>;
tlsSkipVerify?: boolean; tlsSkipVerify?: boolean;
url: string; url: string;
userAccesses?: Array<number>;
}; };
export type SourcesGitAuthenticationPayload = { export type SourcesGitAuthenticationPayload = {
@@ -5860,13 +5877,18 @@ export type PortainerSourceType =
(typeof PortainerSourceType)[keyof typeof PortainerSourceType]; (typeof PortainerSourceType)[keyof typeof PortainerSourceType];
export type PortainerSource = { export type PortainerSource = {
administratorsOnly?: boolean;
git?: GittypesRepoConfig; git?: GittypesRepoConfig;
helm?: PortainerHelmConfig; helm?: PortainerHelmConfig;
id?: number; id?: number;
lastSync?: number; lastSync?: number;
name?: string; name?: string;
ownerID?: number;
public?: boolean;
registry?: PortainerRegistry; registry?: PortainerRegistry;
teamAccesses?: Array<number>;
type?: PortainerSourceType; type?: PortainerSourceType;
userAccesses?: Array<number>;
}; };
export type PortainerRegistryManagementConfiguration = { export type PortainerRegistryManagementConfiguration = {
@@ -11484,6 +11506,50 @@ export type GitOpsSourcesUpdateGitResponses = {
export type GitOpsSourcesUpdateGitResponse = export type GitOpsSourcesUpdateGitResponse =
GitOpsSourcesUpdateGitResponses[keyof GitOpsSourcesUpdateGitResponses]; GitOpsSourcesUpdateGitResponses[keyof GitOpsSourcesUpdateGitResponses];
export type GitOpsSourcesUpdateAccessData = {
/**
* Source access control
*/
body: SourcesSourceAccessUpdatePayload;
path: {
/**
* Source identifier
*/
id: number;
};
query?: never;
url: '/gitops/sources/{id}/access';
};
export type GitOpsSourcesUpdateAccessErrors = {
/**
* Invalid request payload
*/
400: unknown;
/**
* Access denied
*/
403: unknown;
/**
* Source not found
*/
404: unknown;
/**
* Server error
*/
500: unknown;
};
export type GitOpsSourcesUpdateAccessResponses = {
/**
* OK
*/
200: PortainerSource;
};
export type GitOpsSourcesUpdateAccessResponse =
GitOpsSourcesUpdateAccessResponses[keyof GitOpsSourcesUpdateAccessResponses];
export type GitOpsSourcesTestByIdData = { export type GitOpsSourcesTestByIdData = {
/** /**
* Optional connection overrides; omitted fields fall back to stored values * Optional connection overrides; omitted fields fall back to stored values
@@ -1223,7 +1223,14 @@ export const zSourcesAutoUpdateInfo = z.object({
mechanism: z.string().optional(), mechanism: z.string().optional(),
}); });
export const zSourcesSourceAccess = z.object({
public: z.boolean().optional(),
teams: z.array(z.int()).optional(),
users: z.array(z.int()).optional(),
});
export const zSourcesSourceDetail = z.object({ export const zSourcesSourceDetail = z.object({
access: zSourcesSourceAccess.optional(),
autoUpdate: zSourcesAutoUpdateInfo.optional(), autoUpdate: zSourcesAutoUpdateInfo.optional(),
connection: zSourcesConnectionInfo, connection: zSourcesConnectionInfo,
environments: z.int().optional(), environments: z.int().optional(),
@@ -1238,6 +1245,12 @@ export const zSourcesSourceDetail = z.object({
workflows: z.array(zWorkflowsWorkflow).optional(), workflows: z.array(zWorkflowsWorkflow).optional(),
}); });
export const zSourcesSourceAccessUpdatePayload = z.object({
public: z.boolean().optional(),
teams: z.array(z.int()).optional(),
users: z.array(z.int()).optional(),
});
export const zSourcesSource = z.object({ export const zSourcesSource = z.object({
environments: z.int().optional(), environments: z.int().optional(),
error: z.string().optional(), error: z.string().optional(),
@@ -1269,10 +1282,14 @@ export const zSourcesGitAuthenticationPayload = z.object({
}); });
export const zSourcesGitSourceCreatePayload = z.object({ export const zSourcesGitSourceCreatePayload = z.object({
administratorsOnly: z.boolean().optional(),
authentication: zSourcesGitAuthenticationPayload.optional(), authentication: zSourcesGitAuthenticationPayload.optional(),
name: z.string().optional(), name: z.string().optional(),
public: z.boolean().optional(),
teamAccesses: z.array(z.int()).optional(),
tlsSkipVerify: z.boolean().optional(), tlsSkipVerify: z.boolean().optional(),
url: z.string(), url: z.string(),
userAccesses: z.array(z.int()).optional(),
}); });
export const zSourcesConnectionTestResult = z.object({ export const zSourcesConnectionTestResult = z.object({
@@ -1817,13 +1834,18 @@ export const zPortainerHelmConfig = z.object({
}); });
export const zPortainerSource = z.object({ export const zPortainerSource = z.object({
administratorsOnly: z.boolean().optional(),
git: zGittypesRepoConfig.optional(), git: zGittypesRepoConfig.optional(),
helm: zPortainerHelmConfig.optional(), helm: zPortainerHelmConfig.optional(),
id: z.int().optional(), id: z.int().optional(),
lastSync: z.int().optional(), lastSync: z.int().optional(),
name: z.string().optional(), name: z.string().optional(),
ownerID: z.int().optional(),
public: z.boolean().optional(),
registry: zPortainerRegistry.optional(), registry: zPortainerRegistry.optional(),
teamAccesses: z.array(z.int()).optional(),
type: zPortainerSourceType.optional(), type: zPortainerSourceType.optional(),
userAccesses: z.array(z.int()).optional(),
}); });
export const zPortainerEdge = z.object({ export const zPortainerEdge = z.object({
@@ -4087,6 +4109,20 @@ export const zGitOpsSourcesUpdateGitPath = z.object({
*/ */
export const zGitOpsSourcesUpdateGitResponse = zPortainerSource; export const zGitOpsSourcesUpdateGitResponse = zPortainerSource;
/**
* Source access control
*/
export const zGitOpsSourcesUpdateAccessBody = zSourcesSourceAccessUpdatePayload;
export const zGitOpsSourcesUpdateAccessPath = z.object({
id: z.int(),
});
/**
* OK
*/
export const zGitOpsSourcesUpdateAccessResponse = zPortainerSource;
/** /**
* Optional connection overrides; omitted fields fall back to stored values * Optional connection overrides; omitted fields fall back to stored values
*/ */
@@ -2,6 +2,8 @@ import { Form, Formik, FormikHelpers } from 'formik';
import { useRouter } from '@uirouter/react'; import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications'; import { notifySuccess } from '@/portainer/services/notifications';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
@@ -11,7 +13,17 @@ import { WizardStep, useWizardContext } from './WizardContext';
import { WizardHeader } from './WizardHeader'; import { WizardHeader } from './WizardHeader';
import { WizardFooter } from './WizardFooter'; import { WizardFooter } from './WizardFooter';
const initialFormValues: FormValues = { type Props = {
steps: WizardStep[];
};
export function CreateForm({ steps }: Props) {
const isAdmin = useIsPureAdmin();
const mutation = useCreateSourceMutation();
const router = useRouter();
const { currentStep, isLastStep, goToNextStep } = useWizardContext();
const initialFormValues: FormValues = {
name: '', name: '',
type: 'git', type: 'git',
git: { git: {
@@ -21,16 +33,12 @@ const initialFormValues: FormValues = {
}, },
connectionOk: false, connectionOk: false,
}, },
}; authorizedTeams: [],
authorizedUsers: [],
type Props = { ownership: isAdmin
steps: WizardStep[]; ? ResourceControlOwnership.ADMINISTRATORS
}; : ResourceControlOwnership.PRIVATE,
};
export function CreateForm({ steps }: Props) {
const mutation = useCreateSourceMutation();
const router = useRouter();
const { currentStep, isLastStep, goToNextStep } = useWizardContext();
return ( return (
<Formik <Formik
@@ -5,6 +5,10 @@ import { TypeSelectStep, validateTypeSelectStep } from './steps/TypeSelectStep';
import { ConfigureStep, validateConfigureStep } from './steps/ConfigureStep'; import { ConfigureStep, validateConfigureStep } from './steps/ConfigureStep';
import { WizardStep, WizardProvider } from './WizardContext'; import { WizardStep, WizardProvider } from './WizardContext';
import { CreateForm } from './CreateForm'; import { CreateForm } from './CreateForm';
import {
AccessControlStep,
validateAccessControlStep,
} from './steps/AccessControlStep';
const steps: WizardStep[] = [ const steps: WizardStep[] = [
{ {
@@ -19,6 +23,12 @@ const steps: WizardStep[] = [
component: ConfigureStep, component: ConfigureStep,
validateStep: validateConfigureStep, validateStep: validateConfigureStep,
}, },
{
id: 'access',
label: 'Access control',
component: AccessControlStep,
validateStep: validateAccessControlStep,
},
]; ];
export function CreateView() { export function CreateView() {

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