diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index 6b883496e..b5f0a1be3 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -195,9 +195,20 @@ type ( BucketName() string } + SourceServiceUserContext struct { + User *portainer.User + UserMemberships []portainer.TeamMembership + } + // SourceService represents a service for managing GitOps source data SourceService interface { - BaseCRUD[portainer.Source, portainer.SourceID] + Create(context *SourceServiceUserContext, source *portainer.Source) error + Read(context *SourceServiceUserContext, ID portainer.SourceID) (*portainer.Source, error) + Exists(context *SourceServiceUserContext, ID portainer.SourceID) (bool, error) + ReadAll(context *SourceServiceUserContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) + Update(context *SourceServiceUserContext, ID portainer.SourceID, source *portainer.Source) error + Delete(context *SourceServiceUserContext, ID portainer.SourceID) error + FindOrCreateGitSource(context *SourceServiceUserContext, source *portainer.Source) (*portainer.Source, error) } // StackService represents a service for managing stack data diff --git a/api/dataservices/source/access_rules.go b/api/dataservices/source/access_rules.go new file mode 100644 index 000000000..c91057cc7 --- /dev/null +++ b/api/dataservices/source/access_rules.go @@ -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 +} diff --git a/api/dataservices/source/normalize_source.go b/api/dataservices/source/normalize_source.go new file mode 100644 index 000000000..0b6f68b03 --- /dev/null +++ b/api/dataservices/source/normalize_source.go @@ -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 +} diff --git a/api/dataservices/source/sanitize_git.go b/api/dataservices/source/sanitize_git.go new file mode 100644 index 000000000..d79935ec2 --- /dev/null +++ b/api/dataservices/source/sanitize_git.go @@ -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 +} diff --git a/api/dataservices/source/sanitize_git_test.go b/api/dataservices/source/sanitize_git_test.go new file mode 100644 index 000000000..efc015040 --- /dev/null +++ b/api/dataservices/source/sanitize_git_test.go @@ -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{}) +// } diff --git a/api/dataservices/source/source.go b/api/dataservices/source/source.go index 3d97b4b37..9534feca1 100644 --- a/api/dataservices/source/source.go +++ b/api/dataservices/source/source.go @@ -10,7 +10,7 @@ const BucketName = "sources" // Service represents a service for managing GitOps source data. type Service struct { - dataservices.BaseDataService[portainer.Source, portainer.SourceID] + base dataservices.BaseDataService[portainer.Source, portainer.SourceID] } // NewService creates a new instance of a service. @@ -21,7 +21,7 @@ func NewService(connection portainer.Connection) (*Service, error) { } return &Service{ - BaseDataService: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{ + base: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{ Bucket: BucketName, Connection: connection, }, @@ -30,21 +30,77 @@ func NewService(connection portainer.Connection) (*Service, error) { func (service *Service) Tx(tx portainer.Transaction) ServiceTx { return ServiceTx{ - BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{ + base: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{ Bucket: BucketName, - Connection: service.Connection, + Connection: service.base.Connection, Tx: tx, }, } } // Create creates a new source. -func (service *Service) Create(source *portainer.Source) error { - return service.Connection.CreateObject( - BucketName, - func(id uint64) (int, any) { - source.ID = portainer.SourceID(id) - return int(source.ID), source - }, - ) +func (service *Service) Create(context *userContext, source *portainer.Source) error { + return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Create(context, source) + }) +} + +func (service *Service) Read(context *userContext, ID portainer.SourceID) (*portainer.Source, error) { + var result *portainer.Source + + err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).Read(context, ID) + return err + }) + + return result, err +} + +func (service *Service) Exists(context *userContext, ID portainer.SourceID) (bool, error) { + var result bool + + err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).Exists(context, ID) + return err + }) + + return result, err +} + +func (service *Service) ReadAll(context *userContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) { + var result []portainer.Source + + err := service.base.Connection.ViewTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).ReadAll(context, predicates...) + return err + }) + + return result, err +} + +func (service *Service) Update(context *userContext, ID portainer.SourceID, source *portainer.Source) error { + return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Update(context, ID, source) + }) +} + +func (service *Service) Delete(context *userContext, ID portainer.SourceID) error { + return service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + return service.Tx(tx).Delete(context, ID) + }) +} + +func (service *Service) FindOrCreateGitSource(context *userContext, source *portainer.Source) (*portainer.Source, error) { + var result *portainer.Source + + err := service.base.Connection.UpdateTx(func(tx portainer.Transaction) error { + var err error + result, err = service.Tx(tx).FindOrCreateGitSource(context, source) + return err + }) + + return result, err } diff --git a/api/dataservices/source/tx.go b/api/dataservices/source/tx.go index d45cebe74..4923c8eb4 100644 --- a/api/dataservices/source/tx.go +++ b/api/dataservices/source/tx.go @@ -3,15 +3,36 @@ package source import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" ) type ServiceTx struct { - dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID] + base dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID] } // Create creates a new source. -func (service ServiceTx) Create(source *portainer.Source) error { - return service.Tx.CreateObject( +func (service ServiceTx) Create(context *userContext, source *portainer.Source) error { + if err := validateUserContext(context); err != nil { + return err + } + + if source == nil { + return ErrInvalidSource + } + + if err := sanitizeGitSource(source); err != nil { + return err + } + + if err := sanitizeAccesses(context, source, nil); err != nil { + return err + } + + if err := enforceUniqueGitSource(service, source); err != nil { + return err + } + + return service.base.Tx.CreateObject( BucketName, func(id uint64) (int, any) { source.ID = portainer.SourceID(id) @@ -19,3 +40,165 @@ func (service ServiceTx) Create(source *portainer.Source) error { }, ) } + +func (service ServiceTx) Read(context *userContext, ID portainer.SourceID) (*portainer.Source, error) { + if err := validateUserContext(context); err != nil { + return nil, err + } + + source, err := service.base.Read(ID) + if err != nil { + return nil, err + } + + if err := enforceUserPermissions(context, source, actionRead); err != nil { + return nil, err + } + + return source, err +} + +// Access is not enforced on this to avoid the cost of deserialize +// Any user can scan the DB IDs using this method, so be mindful with usage of this func. +func (service ServiceTx) Exists(context *userContext, ID portainer.SourceID) (bool, error) { + if err := validateUserContext(context); err != nil { + return false, err + } + + return service.base.Exists(ID) +} + +// ReadAll fetches all sources the user can access, matching predicates +func (service ServiceTx) ReadAll(context *userContext, predicates ...func(portainer.Source) bool) ([]portainer.Source, error) { + if err := validateUserContext(context); err != nil { + return nil, err + } + + list, err := service.base.ReadAll(predicates...) + if err != nil { + return nil, err + } + + return filterSources(list, context), nil +} + +// Update updates the source of id `ID` with the `source` content +// It validates that the user has access to the source, and has enough permissions to perform the action +func (service ServiceTx) Update(context *userContext, ID portainer.SourceID, source *portainer.Source) error { + if err := validateUserContext(context); err != nil { + return err + } + + originalSource, err := service.base.Read(ID) + if err != nil { + return err + } + + if source == nil || originalSource == nil { + return ErrInvalidSource + } + + if err := enforceUserPermissions(context, originalSource, actionWrite); err != nil { + return err + } + + if err := sanitizeGitSource(source); err != nil { + return err + } + + if err := sanitizeAccesses(context, source, originalSource); err != nil { + return err + } + + if err := enforceUniqueGitSource(service, source); err != nil { + return err + } + + return service.base.Update(ID, source) +} + +// Delete deletes a source +// It validates that the user has access to the source, and has enough permissions to perform the action +func (service ServiceTx) Delete(context *userContext, ID portainer.SourceID) error { + if err := validateUserContext(context); err != nil { + return err + } + + source, err := service.base.Read(ID) + if err != nil { + return err + } + + if err := enforceUserPermissions(context, source, actionWrite); err != nil { + return err + } + + return service.base.Delete(ID) +} + +// FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg, +// or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source; +// per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact. +// The function auto adds the user to an existing source if the user doesn't have access but provided a valid full +// config (URL+Auth) +func (service ServiceTx) FindOrCreateGitSource(context *userContext, src *portainer.Source) (*portainer.Source, error) { + if err := validateUserContext(context); err != nil { + return nil, err + } + + if src == nil || src.Git == nil { + return nil, ErrInvalidSource + } + + normalized, err := normalizeGitSource(src) + if err != nil { + return nil, err + } + + existing, err := service.base.ReadAll(func(s portainer.Source) bool { + n, err := normalizeGitSource(&s) + if err != nil { + return false + } + return normalized.Equal(n) + }) + if err != nil { + return nil, err + } + + if len(existing) > 0 { + allowed := filterSources(existing, context) + if len(allowed) > 0 { + return &allowed[0], nil + } + + // give user access to the first source if he doesn't have access + // to any of the sources that have the same url+auth + existing[0].UserAccesses = append(existing[0].UserAccesses, context.User.ID) + if err := service.base.Update(existing[0].ID, &existing[0]); err != nil { + return nil, err + } + return &existing[0], nil + } + + toCreate := &portainer.Source{ + Name: src.Name, + Type: portainer.SourceTypeGit, + Git: &gittypes.RepoConfig{ + URL: src.Git.URL, + Authentication: src.Git.Authentication, + TLSSkipVerify: src.Git.TLSSkipVerify, + }, + Public: src.Public, + AdministratorsOnly: src.AdministratorsOnly, + UserAccesses: src.UserAccesses, + TeamAccesses: src.TeamAccesses, + OwnerID: src.OwnerID, + } + + if err := service.Create(context, toCreate); err != nil { + return nil, err + } + + return toCreate, nil +} diff --git a/api/dataservices/source/user_context.go b/api/dataservices/source/user_context.go new file mode 100644 index 000000000..041f93bf0 --- /dev/null +++ b/api/dataservices/source/user_context.go @@ -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} +} diff --git a/api/dataservices/source/validation_rules_test.go b/api/dataservices/source/validation_rules_test.go new file mode 100644 index 000000000..22b14cd14 --- /dev/null +++ b/api/dataservices/source/validation_rules_test.go @@ -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)) +// } diff --git a/api/datastore/migrator/migrate_2_43_0.go b/api/datastore/migrator/migrate_2_43_0.go index 05dc98d7c..084d5fa10 100644 --- a/api/datastore/migrator/migrate_2_43_0.go +++ b/api/datastore/migrator/migrate_2_43_0.go @@ -4,6 +4,7 @@ import ( "fmt" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/dataservices/stack" gittypes "github.com/portainer/portainer/api/git/types" @@ -52,9 +53,11 @@ func (lrc *legacyRepoConfig) toRepoConfig() *gittypes.RepoConfig { } type legacyStack struct { - ID int `json:"Id"` - GitConfig *legacyRepoConfig `json:"GitConfig"` - WorkflowID *int + ID int `json:"Id"` + GitConfig *legacyRepoConfig `json:"GitConfig"` + WorkflowID *int + ResourceControl *portainer.ResourceControl `json:"ResourceControl"` + CreatedBy string } // sourceDedupeKey is the identity used to detect duplicate Sources during migration. @@ -98,7 +101,8 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error { return err } - existingSources, err := m.sourceService.ReadAll() + adminUserContext := source.InsecureNewAdminContext() + existingSources, err := m.sourceService.ReadAll(adminUserContext) if err != nil { return err } @@ -122,6 +126,19 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error { var newSrcID portainer.SourceID if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error { + users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(ls.ResourceControl, + func() (portainer.UserID, portainer.UserRole, error) { + user, err := m.userService.Tx(tx).UserByUsername(ls.CreatedBy) + if err != nil { + return 0, 0, err + } + return user.ID, user.Role, nil + }, + func(userId portainer.UserID) ([]portainer.TeamMembership, error) { + return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId) + }, + ) + srcID, exists := sourcesByKey[key] if !exists { @@ -133,12 +150,29 @@ func (m *Migrator) migrateGitConfigToSources_2_43_0() error { Authentication: cfg.Authentication, TLSSkipVerify: cfg.TLSSkipVerify, }, + OwnerID: ownerId, + Public: public, + AdministratorsOnly: adminOnly, + UserAccesses: users, + TeamAccesses: teams, } - if err := m.sourceService.Tx(tx).Create(src); err != nil { + + if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil { return fmt.Errorf("failed to create source for stack %d: %w", ls.ID, err) } srcID = src.ID newSrcID = src.ID + } else { + src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID) + if err != nil { + return fmt.Errorf("failed to read source %d for stack %d: %w", srcID, ls.ID, err) + } + + ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId) + + if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil { + return fmt.Errorf("failed to update source %d for stack %d: %w", srcID, ls.ID, err) + } } liveStack, err := m.stackService.Tx(tx).Read(portainer.StackID(ls.ID)) @@ -186,7 +220,8 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error { return err } - existingSources, err := m.sourceService.ReadAll() + adminUserContext := source.InsecureNewAdminContext() + existingSources, err := m.sourceService.ReadAll(adminUserContext) if err != nil { return err } @@ -215,19 +250,48 @@ func (m *Migrator) migrateCustomTemplateGitConfigToSources_2_43_0() error { var newSrcID portainer.SourceID if err := m.stackService.Connection.UpdateTx(func(tx portainer.Transaction) error { + users, teams, public, adminOnly, ownerId := GetValuesForUsersFromResourceOwnershipAndAccesses_2_43_0(t.ResourceControl, + func() (portainer.UserID, portainer.UserRole, error) { + user, err := m.userService.Tx(tx).Read(t.CreatedByUserID) + if err != nil { + return 0, 0, err + } + return user.ID, user.Role, nil + }, + func(userId portainer.UserID) ([]portainer.TeamMembership, error) { + return m.teamMembershipService.Tx(tx).TeamMembershipsByUserID(userId) + }, + ) + srcID, exists := sourcesByKey[key] if !exists { src := &portainer.Source{ - Name: gittypes.RepoName(cfg.URL), - Type: portainer.SourceTypeGit, - Git: cfg, + Name: gittypes.RepoName(cfg.URL), + Type: portainer.SourceTypeGit, + Git: cfg, + OwnerID: ownerId, + Public: public, + AdministratorsOnly: adminOnly, + UserAccesses: users, + TeamAccesses: teams, } - if err := m.sourceService.Tx(tx).Create(src); err != nil { + if err := m.sourceService.Tx(tx).Create(adminUserContext, src); err != nil { return fmt.Errorf("failed to create source for custom template %d: %w", t.ID, err) } srcID = src.ID newSrcID = src.ID + } else { + src, err := m.sourceService.Tx(tx).Read(adminUserContext, srcID) + if err != nil { + return fmt.Errorf("failed to read source %d for custom template %d: %w", srcID, t.ID, err) + } + + ApplyUACOnSourceUpdate_2_43_0(src, users, teams, public, adminOnly, ownerId) + + if err := m.sourceService.Tx(tx).Update(adminUserContext, srcID, src); err != nil { + return fmt.Errorf("failed to update source %d for custom template %d: %w", srcID, t.ID, err) + } } t.Artifact = &portainer.Artifact{ diff --git a/api/datastore/migrator/migrate_2_43_0_test.go b/api/datastore/migrator/migrate_2_43_0_test.go index 6d5095a58..9734bf142 100644 --- a/api/datastore/migrator/migrate_2_43_0_test.go +++ b/api/datastore/migrator/migrate_2_43_0_test.go @@ -15,6 +15,10 @@ import ( "github.com/stretchr/testify/require" ) +// TODO: generate tests for UAC migrations + +var adminUserContext = source.InsecureNewAdminContext() + func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) { t.Parallel() @@ -61,7 +65,7 @@ func TestMigrateGitConfigToSources_2_43_0_GitStackMigrated(t *testing.T) { require.Len(t, wf.Artifacts, 1) require.Len(t, wf.Artifacts[0].Files, 1) - src, err := sourceSvc.Read(wf.Artifacts[0].Files[0].SourceID) + src, err := sourceSvc.Read(adminUserContext, wf.Artifacts[0].Files[0].SourceID) require.NoError(t, err) require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, gitStack.GitConfig.URL, src.Git.URL) @@ -105,7 +109,7 @@ func TestMigrateGitConfigToSources_2_43_0_NonGitStackUntouched(t *testing.T) { require.Zero(t, result.WorkflowID) require.Nil(t, result.GitConfig) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Empty(t, sources) @@ -161,7 +165,7 @@ func TestMigrateGitConfigToSources_2_43_0_DuplicateSourcesDeduped(t *testing.T) err = m.migrateGitConfigToSources_2_43_0() require.NoError(t, err) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 1, "two stacks with the same URL must share one Source") @@ -215,7 +219,7 @@ func TestMigrateGitConfigToSources_2_43_0_Idempotent(t *testing.T) { err = m.migrateGitConfigToSources_2_43_0() require.NoError(t, err) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 1) @@ -269,7 +273,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_GitTemplateMigrated(t *t require.Equal(t, "docker-compose.yml", migrated.Artifact.Files[0].Path) require.Equal(t, "abc123", migrated.Artifact.Files[0].Hash) - src, err := sourceSvc.Read(migrated.Artifact.Files[0].SourceID) + src, err := sourceSvc.Read(adminUserContext, migrated.Artifact.Files[0].SourceID) require.NoError(t, err) require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, "https://github.com/example/repo", src.Git.URL) @@ -308,7 +312,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_NonGitTemplateUntouched( require.Nil(t, result.Artifact) require.Nil(t, result.GitConfig) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Empty(t, sources) } @@ -351,7 +355,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_AlreadyMigratedSkipped(t err = m.migrateCustomTemplateGitConfigToSources_2_43_0() require.NoError(t, err) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Empty(t, sources, "no new sources should be created for already-migrated templates") } @@ -403,7 +407,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_DuplicateSourcesDeduped( err = m.migrateCustomTemplateGitConfigToSources_2_43_0() require.NoError(t, err) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 1, "two templates with the same URL must share one Source") @@ -457,7 +461,7 @@ func TestMigrateCustomTemplateGitConfigToSources_2_43_0_Idempotent(t *testing.T) err = m.migrateCustomTemplateGitConfigToSources_2_43_0() require.NoError(t, err) - sources, err := sourceSvc.ReadAll() + sources, err := sourceSvc.ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 1) } diff --git a/api/datastore/migrator/migrate_2_43_sources.go b/api/datastore/migrator/migrate_2_43_sources.go new file mode 100644 index 000000000..b8089f4cf --- /dev/null +++ b/api/datastore/migrator/migrate_2_43_sources.go @@ -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 +} diff --git a/api/datastore/services.go b/api/datastore/services.go index 84a574923..548031418 100644 --- a/api/datastore/services.go +++ b/api/datastore/services.go @@ -577,7 +577,7 @@ func (store *Store) Export(filename string) (err error) { backup.SSLSettings = *settings } - if s, err := store.Source().ReadAll(); err != nil { + if s, err := store.Source().ReadAll(source.InsecureNewAdminContext()); err != nil { if !store.IsErrObjectNotFound(err) { log.Error().Err(err).Msg("exporting Sources") } @@ -768,7 +768,7 @@ func (store *Store) Import(filename string) (err error) { } for _, v := range backup.Source { - if err := store.Source().Update(v.ID, &v); err != nil { + if err := store.Source().Update(source.InsecureNewAdminContext(), v.ID, &v); err != nil { log.Warn().Err(err).Msg("failed to update the source in the database") } } diff --git a/api/gitops/sources/helpers_test.go b/api/gitops/sources/helpers_test.go new file mode 100644 index 000000000..7ab31549d --- /dev/null +++ b/api/gitops/sources/helpers_test.go @@ -0,0 +1,5 @@ +package sources + +import "github.com/portainer/portainer/api/dataservices/source" + +var adminUserContext = source.InsecureNewAdminContext() diff --git a/api/gitops/sources/repo_config.go b/api/gitops/sources/repo_config.go index 6e333f58e..d00d7122b 100644 --- a/api/gitops/sources/repo_config.go +++ b/api/gitops/sources/repo_config.go @@ -2,6 +2,7 @@ package sources import ( portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/pkg/fips" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -23,14 +24,14 @@ type RepoConfigInput struct { } // ResolveRepoConfig builds a RepoConfig from either a SourceID or inline URL/auth fields. -func ResolveRepoConfig(tx gitSourceStore, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) { +func ResolveRepoConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, input RepoConfigInput) (gittypes.RepoConfig, *httperror.HandlerError) { cfg := gittypes.RepoConfig{ ReferenceName: input.ReferenceName, ConfigFilePath: input.ConfigFilePath, } if input.SourceID != 0 { - src, httpErr := ValidateGitSourceAccess(tx, input.SourceID) + src, httpErr := ValidateGitSourceAccess(tx, userContext, input.SourceID) if httpErr != nil { return gittypes.RepoConfig{}, httpErr } diff --git a/api/gitops/sources/repo_config_test.go b/api/gitops/sources/repo_config_test.go index f41ee51e8..b6d8b8611 100644 --- a/api/gitops/sources/repo_config_test.go +++ b/api/gitops/sources/repo_config_test.go @@ -30,9 +30,9 @@ func TestResolveRepoConfig_WithSourceID_ReturnsSourceConfig(t *testing.T) { }, }, } - require.NoError(t, store.Source().Create(src)) + require.NoError(t, store.Source().Create(adminUserContext, src)) - cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{ + cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{ SourceID: src.ID, ReferenceName: "refs/heads/main", ConfigFilePath: "docker-compose.yml", @@ -51,7 +51,7 @@ func TestResolveRepoConfig_WithInlineURL_ReturnsInlineConfig(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, false) - cfg, httpErr := ResolveRepoConfig(store, RepoConfigInput{ + cfg, httpErr := ResolveRepoConfig(store, adminUserContext, RepoConfigInput{ ReferenceName: "refs/heads/main", ConfigFilePath: "docker-compose.yml", RepositoryURL: "https://github.com/org/repo", diff --git a/api/gitops/sources/source_access.go b/api/gitops/sources/source_access.go index 682c69425..3e782f3c9 100644 --- a/api/gitops/sources/source_access.go +++ b/api/gitops/sources/source_access.go @@ -16,9 +16,8 @@ type gitSourceStore interface { } // ValidateGitSourceAccess checks that the given Source exists and is a git Source, and returns it. -// TODO(BE-12905): enforce per-user access policies once Source ownership is introduced. -func ValidateGitSourceAccess(tx gitSourceStore, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) { - src, err := tx.Source().Read(sourceID) +func ValidateGitSourceAccess(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, sourceID portainer.SourceID) (*portainer.Source, *httperror.HandlerError) { + src, err := tx.Source().Read(userContext, sourceID) if err != nil { if tx.IsErrObjectNotFound(err) { return nil, httperror.NotFound("Source not found", err) diff --git a/api/gitops/sources/source_access_test.go b/api/gitops/sources/source_access_test.go index 55e541c30..89a95c68f 100644 --- a/api/gitops/sources/source_access_test.go +++ b/api/gitops/sources/source_access_test.go @@ -19,9 +19,9 @@ func TestValidateSourceForStack_ValidGitSource_ReturnsNil(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo"}, } - require.NoError(t, store.Source().Create(src)) + require.NoError(t, store.Source().Create(adminUserContext, src)) - _, httpErr := ValidateGitSourceAccess(store, src.ID) + _, httpErr := ValidateGitSourceAccess(store, adminUserContext, src.ID) assert.Nil(t, httpErr) } @@ -29,21 +29,7 @@ func TestValidateSourceForStack_SourceNotFound_Returns404(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, false) - _, httpErr := ValidateGitSourceAccess(store, portainer.SourceID(999)) + _, httpErr := ValidateGitSourceAccess(store, adminUserContext, portainer.SourceID(999)) require.NotNil(t, httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) } - -func TestValidateSourceForStack_NonGitSource_Returns400(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, false) - - src := &portainer.Source{ - Type: portainer.SourceType(99), // not a git source - } - require.NoError(t, store.Source().Create(src)) - - _, httpErr := ValidateGitSourceAccess(store, src.ID) - require.NotNil(t, httpErr) - assert.Equal(t, http.StatusBadRequest, httpErr.StatusCode) -} diff --git a/api/gitops/workflows/fetch.go b/api/gitops/workflows/fetch.go index a8a018845..af5345a43 100644 --- a/api/gitops/workflows/fetch.go +++ b/api/gitops/workflows/fetch.go @@ -5,6 +5,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/kubernetes/cli" @@ -22,6 +23,8 @@ func FetchWorkflows( ) ([]Workflow, error) { gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{} + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) }) @@ -50,7 +53,7 @@ func FetchWorkflows( workflowIDSet.Add(stack.WorkflowID) } - workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, workflowIDSet) + workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet) if err != nil { return nil, err } @@ -113,7 +116,9 @@ func FetchSourceStats( k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ) ([]portainer.Source, map[portainer.SourceID]SourceStats, error) { - sources, err := tx.Source().ReadAll() + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + + sources, err := tx.Source().ReadAll(userContext) if err != nil { return nil, nil, err } diff --git a/api/gitops/workflows/fetch_test.go b/api/gitops/workflows/fetch_test.go index 481eca78d..717dc9500 100644 --- a/api/gitops/workflows/fetch_test.go +++ b/api/gitops/workflows/fetch_test.go @@ -15,7 +15,11 @@ import ( ) func adminContext() *security.RestrictedRequestContext { - return &security.RestrictedRequestContext{IsAdmin: true, UserID: 1} + return &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + } } func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack) { @@ -24,7 +28,7 @@ func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *por cfg := stack.GitConfig src := &portainer.Source{Type: portainer.SourceTypeGit, Git: cfg} - require.NoError(t, tx.Source().Create(src)) + require.NoError(t, tx.Source().Create(adminUserContext, src)) wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ StackID: stack.ID, @@ -199,8 +203,8 @@ func TestFetchSourceStats_ReturnsAllSources(t *testing.T) { _, store := datastore.MustNewTestStore(t, false, true) require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit})) - require.NoError(t, tx.Source().Create(&portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit})) + require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-1", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo1"}})) + require.NoError(t, tx.Source().Create(adminUserContext, &portainer.Source{Name: "source-2", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo2"}})) return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) })) @@ -223,8 +227,8 @@ func TestFetchSourceStats_TracksWorkflowCountAndEndpoints(t *testing.T) { var srcID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit} - require.NoError(t, tx.Source().Create(src)) + src := &portainer.Source{Name: "shared", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) srcID = src.ID for i := 1; i <= 2; i++ { @@ -261,8 +265,8 @@ func TestFetchSourceStats_UnusedSourceHasZeroStats(t *testing.T) { var unusedID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit} - require.NoError(t, tx.Source().Create(src)) + src := &portainer.Source{Name: "unused", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + require.NoError(t, tx.Source().Create(adminUserContext, src)) unusedID = src.ID return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) diff --git a/api/gitops/workflows/helpers_test.go b/api/gitops/workflows/helpers_test.go new file mode 100644 index 000000000..5231abb97 --- /dev/null +++ b/api/gitops/workflows/helpers_test.go @@ -0,0 +1,7 @@ +package workflows + +import ( + "github.com/portainer/portainer/api/dataservices/source" +) + +var adminUserContext = source.InsecureNewAdminContext() diff --git a/api/gitops/workflows/source_artifact.go b/api/gitops/workflows/source_artifact.go index d2a4a1c95..8b3404214 100644 --- a/api/gitops/workflows/source_artifact.go +++ b/api/gitops/workflows/source_artifact.go @@ -20,7 +20,7 @@ type gitSourceStore interface { // from the workflow identified by workflowID. // Source carries the shared fields (URL, auth, TLS); ArtifactFile carries the file-specific fields (ref, path, hash). // Returns nil, nil, nil when workflowID is 0 or no matching entry is found. -func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) { +func GitSourceAndArtifactForStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*portainer.Source, *portainer.ArtifactFile, error) { if workflowID == 0 { return nil, nil, nil } @@ -30,7 +30,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.Workfl return nil, nil, err } - sourceMap, err := loadWorkflowSources(tx, wf) + sourceMap, err := loadWorkflowSources(tx, userContext, wf) if err != nil { return nil, nil, err } @@ -57,7 +57,7 @@ func GitSourceAndArtifactForStack(tx gitSourceStore, workflowID portainer.Workfl // GitSourceAndArtifactForEdgeStack returns the git Source and the ArtifactFile matching edgeStackID. // Returns nil, nil, nil when workflowID is 0 or no matching entry is found. -func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) { +func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, edgeStackID portainer.EdgeStackID) (*portainer.Source, *portainer.ArtifactFile, error) { if workflowID == 0 { return nil, nil, nil } @@ -67,7 +67,7 @@ func GitSourceAndArtifactForEdgeStack(tx gitSourceStore, workflowID portainer.Wo return nil, nil, err } - sourceMap, err := loadWorkflowSources(tx, wf) + sourceMap, err := loadWorkflowSources(tx, userContext, wf) if err != nil { return nil, nil, err } @@ -169,45 +169,15 @@ func UpdateArtifactFileForEdgeStack(tx gitSourceStore, workflowID portainer.Work // FindOrCreateGitSource returns an existing Source whose URL and authentication match cfg, // or creates a new one. Only URL, authentication, and TLSSkipVerify are stored on the Source; // per-stack fields (ReferenceName, ConfigFilePath, ConfigHash) belong in the Artifact. -func FindOrCreateGitSource(tx gitSourceStore, src *portainer.Source) (*portainer.Source, error) { - src.Git.URL = gittypes.SanitizeURL(src.Git.URL) - - existing, err := tx.Source().ReadAll(func(s portainer.Source) bool { - return s.Type == portainer.SourceTypeGit && - s.Git != nil && - s.Git.URL == src.Git.URL && - gitAuthMatches(s.Git.Authentication, src.Git.Authentication) - }) - if err != nil { - return nil, err - } - - if len(existing) > 0 { - return &existing[0], nil - } - - toCreate := &portainer.Source{ - Name: src.Name, - Type: portainer.SourceTypeGit, - Git: &gittypes.RepoConfig{ - URL: src.Git.URL, - Authentication: src.Git.Authentication, - TLSSkipVerify: src.Git.TLSSkipVerify, - }, - } - - if err := tx.Source().Create(toCreate); err != nil { - return nil, err - } - - return toCreate, nil +func FindOrCreateGitSource(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, src *portainer.Source) (*portainer.Source, error) { + return tx.Source().FindOrCreateGitSource(userContext, src) } // SaveWorkflowGitConfig persists URL/auth/TLS on the Source and ref/path/hash on the Artifact // matched by matchArtifact. When the URL changes, an existing or new Source is located via // FindOrCreateGitSource and the Workflow's SourceID is updated atomically alongside the Artifact fields. -func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { - src, err := tx.Source().Read(oldSourceID) +func SaveWorkflowGitConfig(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, matchArtifact func(portainer.Artifact) bool, oldSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { + src, err := tx.Source().Read(userContext, oldSourceID) if err != nil { return fmt.Errorf("failed to read source: %w", err) } @@ -219,7 +189,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m newSourceID := oldSourceID if cfg.URL != src.Git.URL { - newSrc, err := FindOrCreateGitSource(tx, &portainer.Source{ + newSrc, err := FindOrCreateGitSource(tx, userContext, &portainer.Source{ Name: gittypes.RepoName(cfg.URL), Type: portainer.SourceTypeGit, Git: cfg, @@ -233,7 +203,7 @@ func SaveWorkflowGitConfig(tx gitSourceStore, workflowID portainer.WorkflowID, m src.Git.Authentication = cfg.Authentication src.Git.TLSSkipVerify = cfg.TLSSkipVerify - if err := tx.Source().Update(src.ID, src); err != nil { + if err := tx.Source().Update(userContext, src.ID, src); err != nil { return fmt.Errorf("failed to update source: %w", err) } } @@ -297,7 +267,7 @@ func LoadWorkflowMap(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[ // LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference, // collecting source IDs in a single pass over the workflows. -func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) { +func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) { wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids)) sourceIDs := make(set.Set[portainer.SourceID]) for id := range ids { @@ -313,7 +283,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.Workflow } } - srcMap, err := LoadSourceMap(tx, sourceIDs) + srcMap, err := loadSourceMap(tx, userContext, sourceIDs) if err != nil { return nil, nil, err } @@ -323,7 +293,7 @@ func LoadWorkflowAndSourceMaps(tx gitSourceStore, ids set.Set[portainer.Workflow // loadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map. // This avoids reading the same Source record more than once when files share a SourceID. -func loadWorkflowSources(tx gitSourceStore, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) { +func loadWorkflowSources(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) { ids := make(set.Set[portainer.SourceID]) for _, as := range wf.Artifacts { for _, f := range as.Files { @@ -331,67 +301,22 @@ func loadWorkflowSources(tx gitSourceStore, wf *portainer.Workflow) (map[portain } } - return LoadSourceMap(tx, ids) + return loadSourceMap(tx, userContext, ids) } -// LoadSourceMap fetches sources by their IDs and returns them keyed by ID. -func LoadSourceMap(tx gitSourceStore, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) { +// loadSourceMap fetches sources by their IDs and returns them keyed by ID. +func loadSourceMap(tx gitSourceStore, userContext *dataservices.SourceServiceUserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) { + sources, err := tx.Source().ReadAll(userContext, func(s portainer.Source) bool { + return ids.Contains(s.ID) + }) + if err != nil { + return nil, err + } + result := make(map[portainer.SourceID]portainer.Source, len(ids)) - for id := range ids { - src, err := tx.Source().Read(id) - if err != nil { - return nil, err - } - result[id] = *src + for _, src := range sources { + result[src.ID] = src } return result, nil } - -func gitAuthMatches(a, b *gittypes.GitAuthentication) bool { - if a == nil && b == nil { - return true - } - - if a == nil || b == nil { - return false - } - - return a.Username == b.Username && a.Password == b.Password -} - -// ValidateUniqueSource validates there are no other sources with the same URL and credentials. -// Pass empty strings for username and password when the source has no authentication. -func ValidateUniqueSource(tx gitSourceStore, url, username, password string, sourceID portainer.SourceID) (bool, error) { - normalizedURL, err := gittypes.NormalizeURL(gittypes.SanitizeURL(url)) - if err != nil { - return false, err - } - - existing, err := tx.Source().ReadAll(func(s portainer.Source) bool { - if s.ID == sourceID || s.Type != portainer.SourceTypeGit || s.Git == nil { - return false - } - - normalized, err := gittypes.NormalizeURL(gittypes.SanitizeURL(s.Git.URL)) - if err != nil || normalized != normalizedURL { - return false - } - - existingUsername, existingPassword := gitAuthCredentials(s.Git.Authentication) - return existingUsername == username && existingPassword == password - }) - - if err != nil { - return false, err - } - - return len(existing) == 0, nil -} - -func gitAuthCredentials(auth *gittypes.GitAuthentication) (username, password string) { - if auth == nil { - return "", "" - } - return auth.Username, auth.Password -} diff --git a/api/gitops/workflows/source_artifact_test.go b/api/gitops/workflows/source_artifact_test.go index 230ddda51..5bff40656 100644 --- a/api/gitops/workflows/source_artifact_test.go +++ b/api/gitops/workflows/source_artifact_test.go @@ -80,7 +80,7 @@ func TestGitSourceAndArtifactForStack_ZeroWorkflowIDReturnsNil(t *testing.T) { var file *portainer.ArtifactFile err := store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForStack(tx, 0, 1) + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, 0, 1) return txErr }) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T) Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, } - err := tx.Source().Create(gitSrc) + err := tx.Source().Create(adminUserContext, gitSrc) require.NoError(t, err) wf := &portainer.Workflow{ @@ -124,7 +124,7 @@ func TestGitSourceAndArtifactForStack_ReturnsMatchingSourceAndFile(t *testing.T) var file *portainer.ArtifactFile err = store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 42) + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 42) return txErr }) require.NoError(t, err) @@ -146,7 +146,7 @@ func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T) Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) wf := &portainer.Workflow{ @@ -167,43 +167,7 @@ func TestGitSourceAndArtifactForStack_NoMatchingArtifactReturnsNil(t *testing.T) var file *portainer.ArtifactFile err = store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 99) - return txErr - }) - require.NoError(t, err) - require.Nil(t, src) - require.Nil(t, file) -} - -func TestGitSourceAndArtifactForStack_NonGitSourceSkipped(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - var workflowID portainer.WorkflowID - err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { - nonGitSrc := &portainer.Source{Type: portainer.SourceType(99)} - err := tx.Source().Create(nonGitSrc) - require.NoError(t, err) - - wf := &portainer.Workflow{ - Artifacts: []portainer.Artifact{{ - StackID: 1, - Files: []portainer.ArtifactFile{{SourceID: nonGitSrc.ID}}, - }}, - } - err = tx.Workflow().Create(wf) - require.NoError(t, err) - workflowID = wf.ID - - return nil - }) - require.NoError(t, err) - - var src *portainer.Source - var file *portainer.ArtifactFile - err = store.ViewTx(func(tx dataservices.DataStoreTx) error { - var txErr error - src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 1) + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 99) return txErr }) require.NoError(t, err) @@ -219,7 +183,7 @@ func TestGitSourceAndArtifactForEdgeStack_ZeroWorkflowIDReturnsNil(t *testing.T) var file *portainer.ArtifactFile err := store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, 0, 1) + src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, 0, 1) return txErr }) require.NoError(t, err) @@ -237,7 +201,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/edge-repo"}, } - err := tx.Source().Create(gitSrc) + err := tx.Source().Create(adminUserContext, gitSrc) require.NoError(t, err) wf := &portainer.Workflow{ @@ -262,7 +226,7 @@ func TestGitSourceAndArtifactForEdgeStack_ReturnsMatchingSourceAndFile(t *testin var file *portainer.ArtifactFile err = store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 5) + src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 5) return txErr }) require.NoError(t, err) @@ -280,7 +244,7 @@ func TestUpdateArtifactFileForStack_NoMatchingArtifactIsNoOp(t *testing.T) { var sourceID portainer.SourceID err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) sourceID = src.ID @@ -318,7 +282,7 @@ func TestUpdateArtifactFileForStack_AppliesFnAndPersists(t *testing.T) { var sourceID portainer.SourceID err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) sourceID = src.ID @@ -356,7 +320,7 @@ func TestUpdateArtifactFileForEdgeStack_AppliesFnAndPersists(t *testing.T) { var sourceID portainer.SourceID err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) sourceID = src.ID @@ -393,7 +357,7 @@ func TestFindOrCreateGitSource_CreatesNewSource(t *testing.T) { var src *portainer.Source err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, txErr = FindOrCreateGitSource(tx, &portainer.Source{ + src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ Name: "my-repo", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ @@ -413,7 +377,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing. _, store := datastore.MustNewTestStore(t, false, true) makeSource := func(tx dataservices.DataStoreTx) (*portainer.Source, error) { - return FindOrCreateGitSource(tx, &portainer.Source{ + return FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ URL: "https://github.com/example/repo", @@ -446,7 +410,7 @@ func TestFindOrCreateGitSource_ReusesExistingSourceForSameURLAndAuth(t *testing. require.NoError(t, err) require.Equal(t, firstID, secondID) - sources, err := store.Source().ReadAll() + sources, err := store.Source().ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 1) } @@ -456,7 +420,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) { _, store := datastore.MustNewTestStore(t, false, true) err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { - _, txErr := FindOrCreateGitSource(tx, &portainer.Source{ + _, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ URL: "https://github.com/example/repo", @@ -468,7 +432,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) { require.NoError(t, err) err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { - _, txErr := FindOrCreateGitSource(tx, &portainer.Source{ + _, txErr := FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ URL: "https://github.com/example/repo", @@ -479,7 +443,7 @@ func TestFindOrCreateGitSource_DifferentAuthCreatesNewSource(t *testing.T) { }) require.NoError(t, err) - sources, err := store.Source().ReadAll() + sources, err := store.Source().ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 2) } @@ -503,7 +467,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T }, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) sourceID = src.ID @@ -539,7 +503,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T } err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { return a.StackID == 1 }, sourceID, newCfg) }) @@ -552,7 +516,7 @@ func TestSaveWorkflowGitConfig_UpdatesFileAndSourceWhenURLUnchanged(t *testing.T require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) require.Equal(t, sourceID, wf.Artifacts[0].Files[0].SourceID) - src, err := store.Source().Read(sourceID) + src, err := store.Source().Read(adminUserContext, sourceID) require.NoError(t, err) require.Equal(t, "new-user", src.Git.Authentication.Username) require.Equal(t, "new-pass", src.Git.Authentication.Password) @@ -571,7 +535,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"}, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) oldSourceID = src.ID @@ -592,7 +556,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) { newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/new-repo"} err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { return a.StackID == 1 }, oldSourceID, newCfg) }) @@ -603,7 +567,7 @@ func TestSaveWorkflowGitConfig_CreatesNewSourceOnURLChange(t *testing.T) { newSourceID := wf.Artifacts[0].Files[0].SourceID require.NotEqual(t, oldSourceID, newSourceID) - newSrc, err := store.Source().Read(newSourceID) + newSrc, err := store.Source().Read(adminUserContext, newSourceID) require.NoError(t, err) require.Equal(t, "https://github.com/example/new-repo", newSrc.Git.URL) } @@ -620,7 +584,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/old-repo"}, } - err := tx.Source().Create(old) + err := tx.Source().Create(adminUserContext, old) require.NoError(t, err) oldSourceID = old.ID @@ -628,7 +592,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"}, } - err = tx.Source().Create(existing) + err = tx.Source().Create(adminUserContext, existing) require.NoError(t, err) existingSourceID = existing.ID @@ -649,7 +613,7 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) { newCfg := &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"} err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { return a.StackID == 1 }, oldSourceID, newCfg) }) @@ -659,46 +623,11 @@ func TestSaveWorkflowGitConfig_ReusesExistingSourceOnURLChange(t *testing.T) { require.NoError(t, err) require.Equal(t, existingSourceID, wf.Artifacts[0].Files[0].SourceID) - sources, err := store.Source().ReadAll() + sources, err := store.Source().ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 2) } -func TestSaveWorkflowGitConfig_NilGitConfigReturnsError(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - var workflowID portainer.WorkflowID - var sourceID portainer.SourceID - - err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) - require.NoError(t, err) - sourceID = src.ID - - wf := &portainer.Workflow{ - Artifacts: []portainer.Artifact{{ - StackID: 1, - Files: []portainer.ArtifactFile{{SourceID: sourceID}}, - }}, - } - err = tx.Workflow().Create(wf) - require.NoError(t, err) - workflowID = wf.ID - - return nil - }) - require.NoError(t, err) - - err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { - return a.StackID == 1 - }, sourceID, &gittypes.RepoConfig{URL: "https://github.com/example/repo"}) - }) - require.Error(t, err) -} - func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) @@ -711,7 +640,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) sourceID = src.ID @@ -736,7 +665,7 @@ func TestSaveWorkflowGitConfig_OnlyMatchingArtifactUpdated(t *testing.T) { require.NoError(t, err) err = store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return SaveWorkflowGitConfig(tx, workflowID, func(a portainer.Artifact) bool { + return SaveWorkflowGitConfig(tx, adminUserContext, workflowID, func(a portainer.Artifact) bool { return a.StackID == 1 }, sourceID, &gittypes.RepoConfig{ URL: "https://github.com/example/repo", @@ -759,7 +688,7 @@ func TestUpdateArtifactFileForStack_MultipleArtifactsOnlyMatchingUpdated(t *test var srcID portainer.SourceID err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -804,7 +733,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, } - err := tx.Source().Create(old) + err := tx.Source().Create(adminUserContext, old) require.NoError(t, err) oldSourceID = old.ID @@ -818,7 +747,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) { }, }, } - err = tx.Source().Create(selected) + err = tx.Source().Create(adminUserContext, selected) require.NoError(t, err) newSourceID = selected.ID @@ -861,7 +790,7 @@ func TestSaveWorkflowArtifact_SwitchesSourceWithoutMutatingIt(t *testing.T) { require.Equal(t, "new-hash", wf.Artifacts[0].Files[0].Hash) // The selected source's git config must be left untouched. - selected, err := store.Source().Read(newSourceID) + selected, err := store.Source().Read(adminUserContext, newSourceID) require.NoError(t, err) require.Equal(t, "https://github.com/example/repo", selected.Git.URL) require.Equal(t, "selected-user", selected.Git.Authentication.Username) @@ -876,7 +805,7 @@ func TestUpdateArtifactFileForEdgeStack_MultipleArtifactsOnlyMatchingUpdated(t * var srcID portainer.SourceID err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://example.com"}} - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -919,7 +848,7 @@ func TestSaveWorkflowArtifact_SameSourceUpdatesArtifactOnly(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) sourceID = src.ID @@ -971,7 +900,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-repo"}, } - err := tx.Source().Create(gitSrc) + err := tx.Source().Create(adminUserContext, gitSrc) require.NoError(t, err) wf := &portainer.Workflow{ @@ -992,7 +921,7 @@ func TestGitSourceAndArtifactForStack_MultipleArtifactsReturnsCorrectOne(t *test var file *portainer.ArtifactFile err = store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForStack(tx, workflowID, 20) + src, file, txErr = GitSourceAndArtifactForStack(tx, adminUserContext, workflowID, 20) return txErr }) require.NoError(t, err) @@ -1012,7 +941,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t * Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/shared-edge-repo"}, } - err := tx.Source().Create(gitSrc) + err := tx.Source().Create(adminUserContext, gitSrc) require.NoError(t, err) wf := &portainer.Workflow{ @@ -1033,7 +962,7 @@ func TestGitSourceAndArtifactForEdgeStack_MultipleArtifactsReturnsCorrectOne(t * var file *portainer.ArtifactFile err = store.ViewTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, workflowID, 20) + src, file, txErr = GitSourceAndArtifactForEdgeStack(tx, adminUserContext, workflowID, 20) return txErr }) require.NoError(t, err) @@ -1070,7 +999,7 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) { var src *portainer.Source err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { var txErr error - src, txErr = FindOrCreateGitSource(tx, &portainer.Source{ + src, txErr = FindOrCreateGitSource(tx, adminUserContext, &portainer.Source{ Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ URL: "https://user:secret@github.com/example/repo", @@ -1081,97 +1010,3 @@ func TestFindOrCreateGitSource_StripsEmbeddedCredentialsFromURL(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://github.com/example/repo", src.Git.URL) } - -func newSourceWithAuth(url, username, password string) *portainer.Source { - return &portainer.Source{ - Type: portainer.SourceTypeGit, - Git: &gittypes.RepoConfig{ - URL: url, - Authentication: &gittypes.GitAuthentication{ - Username: username, - Password: password, - }, - }, - } -} - -func newAuthlessSource(url string) *portainer.Source { - return &portainer.Source{ - Type: portainer.SourceTypeGit, - Git: &gittypes.RepoConfig{URL: url}, - } -} - -func validateUniqueSourceInStore(t *testing.T, store *datastore.Store, url, username, password string, sourceID portainer.SourceID) bool { - t.Helper() - - var isUnique bool - require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { - var err error - isUnique, err = ValidateUniqueSource(tx, url, username, password, sourceID) - return err - })) - - return isUnique -} - -func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")) - })) - - require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0)) -} - -func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return tx.Source().Create(newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")) - })) - - require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0)) -} - -func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git")) - })) - - require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0)) -} - -func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - return tx.Source().Create(newAuthlessSource("https://github.com/org/repo.git")) - })) - - require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0)) -} - -func TestValidateUniqueSource_ExcludesSelf(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - var srcID portainer.SourceID - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret") - if err := tx.Source().Create(src); err != nil { - return err - } - srcID = src.ID - return nil - })) - - require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID)) -} diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index 2d95ecfaa..0b2a74578 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -9,6 +9,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/sources" @@ -32,9 +33,9 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ return httperror.BadRequest("Invalid query parameter: method", err) } - tokenData, err := security.RetrieveTokenData(r) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to retrieve user details from authentication token", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } customTemplate, err := handler.createCustomTemplate(method, r) @@ -42,16 +43,16 @@ func (handler *Handler) customTemplateCreate(w http.ResponseWriter, r *http.Requ return httperror.InternalServerError("Unable to create custom template", err) } - customTemplate.CreatedByUserID = tokenData.ID + customTemplate.CreatedByUserID = securityContext.UserID err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - return createCustomTemplateTx(tx, customTemplate, tokenData.ID) + return createCustomTemplateTx(tx, customTemplate, securityContext) }) return response.TxResponse(w, customTemplate, err) } -func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, userID portainer.UserID) error { +func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portainer.CustomTemplate, sc *security.RestrictedRequestContext) error { existingTemplates, err := tx.CustomTemplate().ReadAll() if err != nil { return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) @@ -67,14 +68,16 @@ func createCustomTemplateTx(tx dataservices.DataStoreTx, customTemplate *portain return httperror.InternalServerError("Unable to create custom template", err) } - resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, userID) + resourceControl := authorization.NewPrivateResourceControl(strconv.Itoa(int(customTemplate.ID)), portainer.CustomTemplateResourceControl, sc.UserID) if err := tx.ResourceControl().Create(resourceControl); err != nil { return httperror.InternalServerError("Unable to persist resource control inside the database", err) } customTemplate.ResourceControl = resourceControl - populateGitConfig(tx, customTemplate) + + userContext := source.NewUserContext(sc.User, sc.UserMemberships) + populateGitConfig(tx, userContext, customTemplate) return nil } @@ -282,6 +285,11 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( return nil, err } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() customTemplate := &portainer.CustomTemplate{ ID: portainer.CustomTemplateID(customTemplateID), @@ -302,7 +310,9 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( projectPath := getProjectPath() customTemplate.ProjectPath = projectPath - gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{ + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{ SourceID: payload.SourceID, ReferenceName: payload.RepositoryReferenceName, ConfigFilePath: payload.ComposeFilePathInRepository, @@ -327,7 +337,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( sourceID := payload.SourceID if sourceID == 0 { - src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{ + src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{ Name: gittypes.RepoName(gitConfig.URL), Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ diff --git a/api/http/handler/customtemplates/customtemplate_create_test.go b/api/http/handler/customtemplates/customtemplate_create_test.go index 619f5c2b3..e2aef3326 100644 --- a/api/http/handler/customtemplates/customtemplate_create_test.go +++ b/api/http/handler/customtemplates/customtemplate_create_test.go @@ -30,7 +30,14 @@ func createTemplateRequest(t *testing.T, method string, payload any, userID port r.Header.Set("Content-Type", "application/json") r = mux.SetURLVars(r, map[string]string{"method": method}) - return r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: userID, Role: role}) + r = r.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: role == portainer.AdministratorRole, + User: &portainer.User{ID: userID, Role: role}, + }) + return r.WithContext(ctx) } func TestCustomTemplateCreate_FromFileContent_Success(t *testing.T) { @@ -272,7 +279,13 @@ func TestCustomTemplateCreate_FromFileUpload_Success(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -461,7 +474,13 @@ func TestCustomTemplateCreate_FromFileUpload_MissingTitle(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -497,7 +516,13 @@ func TestCustomTemplateCreate_FromFileUpload_MissingDescription(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -530,7 +555,13 @@ func TestCustomTemplateCreate_FromFileUpload_MissingFile(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -569,7 +600,13 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidType(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -608,7 +645,13 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidPlatform(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -650,7 +693,13 @@ func TestCustomTemplateCreate_FromFileUpload_NoteWithImage(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -689,7 +738,13 @@ func TestCustomTemplateCreate_FromFileUpload_KubernetesIgnoresPlatform(t *testin r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -748,7 +803,13 @@ func TestCustomTemplateCreate_FromFileUpload_Variables(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -804,7 +865,13 @@ func TestCustomTemplateCreate_FromFileUpload_InvalidVariables(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/custom_templates/create/file", &body) r.Header.Set("Content-Type", writer.FormDataContentType()) r = mux.SetURLVars(r, map[string]string{"method": "file"}) - r = r.WithContext(security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole})) + ctx := security.StoreTokenData(r, &portainer.TokenData{ID: 1, Role: portainer.AdministratorRole}) + r = r.WithContext(ctx) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{ + UserID: 1, + IsAdmin: true, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + })) rr := httptest.NewRecorder() herr := handler.customTemplateCreate(rr, r) @@ -866,7 +933,7 @@ func TestCustomTemplateCreate_FromRepository_Success(t *testing.T) { require.NoError(t, err) require.NotNil(t, stored.Artifact) - src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID) + src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID) require.NoError(t, err) require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, "https://github.com/example/repo", src.Git.URL) @@ -903,7 +970,7 @@ func TestCustomTemplateCreate_FromRepository_DeduplicatesSource(t *testing.T) { require.Nil(t, herr) err := ds.ViewTx(func(tx dataservices.DataStoreTx) error { - sources, err := tx.Source().ReadAll() + sources, err := tx.Source().ReadAll(adminUserContext) require.NoError(t, err) require.Len(t, sources, 1, "two templates with the same URL must share one Source") @@ -1052,7 +1119,7 @@ func TestCustomTemplateCreate_FromRepository_WithSourceID_Success(t *testing.T) URL: "https://github.com/example/repo", }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID return nil diff --git a/api/http/handler/customtemplates/customtemplate_file_test.go b/api/http/handler/customtemplates/customtemplate_file_test.go index 7baec4f0b..5fb0a7a2c 100644 --- a/api/http/handler/customtemplates/customtemplate_file_test.go +++ b/api/http/handler/customtemplates/customtemplate_file_test.go @@ -153,7 +153,7 @@ func TestCustomTemplateFile_GitTemplate(t *testing.T) { Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/example/repo"}, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) path, err := fs.StoreCustomTemplateFileFromBytes("10", configFilePath, []byte(templateContent)) diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch.go b/api/http/handler/customtemplates/customtemplate_git_fetch.go index 6230ab812..b9c4e80cf 100644 --- a/api/http/handler/customtemplates/customtemplate_git_fetch.go +++ b/api/http/handler/customtemplates/customtemplate_git_fetch.go @@ -7,7 +7,10 @@ import ( "sync" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/stackutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" @@ -49,9 +52,23 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re file := customTemplate.Artifact.Files[0] - src, err := handler.DataStore.Source().Read(file.SourceID) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to retrieve git source for custom template", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var src *portainer.Source + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + src, err = tx.Source().Read(userContext, file.SourceID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve git source for custom template", err) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) } if src.Git == nil { diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go index 9a94aca66..1ae7762de 100644 --- a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go +++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go @@ -174,13 +174,14 @@ func Test_customTemplateGitFetch(t *testing.T) { require.NoError(t, err, "error to get working directory") src := &portainer.Source{ - ID: 1, - Type: portainer.SourceTypeGit, + ID: 1, + Type: portainer.SourceTypeGit, + Public: true, Git: &gittypes.RepoConfig{ URL: "https://github.com/example/repo", }, } - err = store.Source().Create(src) + err = store.Source().Create(adminUserContext, src) require.NoError(t, err, "error creating source") const configFilePath = "test-config-path.txt" @@ -336,31 +337,3 @@ func TestCustomTemplateGitFetch_EmptySourceIDsReturnsBadRequest(t *testing.T) { require.Equal(t, http.StatusBadRequest, rr.Code) } - -func TestCustomTemplateGitFetch_SourceWithNilGitConfigReturnsInternalError(t *testing.T) { - t.Parallel() - - _, store := datastore.MustNewTestStore(t, false, true) - - src := &portainer.Source{Type: portainer.SourceTypeGit} - err := store.Source().Create(src) - require.NoError(t, err) - - template := &portainer.CustomTemplate{ - ID: 1, - Title: "nil-git-config", - Artifact: &portainer.Artifact{ - Files: []portainer.ArtifactFile{{SourceID: src.ID}}, - }, - } - err = store.CustomTemplateService.Create(template) - require.NoError(t, err) - - h := NewHandler(testhelpers.NewTestRequestBouncer(), store, &TestFileService{}, &TestGitService{}) - - req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBufferString("{}")) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, req) - - require.Equal(t, http.StatusInternalServerError, rr.Code) -} diff --git a/api/http/handler/customtemplates/customtemplate_inspect.go b/api/http/handler/customtemplates/customtemplate_inspect.go index 95c658c9e..47863a498 100644 --- a/api/http/handler/customtemplates/customtemplate_inspect.go +++ b/api/http/handler/customtemplates/customtemplate_inspect.go @@ -6,6 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -70,7 +71,8 @@ func (handler *Handler) customTemplateInspect(w http.ResponseWriter, r *http.Req return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) } - populateGitConfig(tx, customTemplate) + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + populateGitConfig(tx, userContext, customTemplate) return nil }) diff --git a/api/http/handler/customtemplates/customtemplate_inspect_test.go b/api/http/handler/customtemplates/customtemplate_inspect_test.go index 2b9698075..596e80421 100644 --- a/api/http/handler/customtemplates/customtemplate_inspect_test.go +++ b/api/http/handler/customtemplates/customtemplate_inspect_test.go @@ -174,7 +174,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) { TLSSkipVerify: true, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -194,7 +194,7 @@ func TestInspectHandler_GitConfigPopulatedFromSource(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/custom_templates/10", nil) r = mux.SetURLVars(r, map[string]string{"id": "10"}) - ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) + ctx := security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}}) r = r.WithContext(ctx) rr := httptest.NewRecorder() herr := handler.customTemplateInspect(rr, r) diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index 36bcbff9d..5c50203a6 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -5,6 +5,8 @@ import ( "strconv" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/slicesx" @@ -37,47 +39,54 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques edge := retrieveEdgeParam(r) - customTemplates, err := handler.DataStore.CustomTemplate().ReadAll() - if err != nil { - return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) - } - - resourceControls, err := handler.DataStore.ResourceControl().ReadAll() - if err != nil { - return httperror.InternalServerError("Unable to retrieve resource controls from the database", err) - } - - customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls) - securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return httperror.InternalServerError("Unable to retrieve info from request context", err) } - if !securityContext.IsAdmin { - user, err := handler.DataStore.User().Read(securityContext.UserID) + var customTemplates []portainer.CustomTemplate + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + customTemplates, err = tx.CustomTemplate().ReadAll() if err != nil { - return httperror.InternalServerError("Unable to retrieve user information from the database", err) + return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) } - userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships) + resourceControls, err := tx.ResourceControl().ReadAll() + if err != nil { + return httperror.InternalServerError("Unable to retrieve resource controls from the database", err) + } - customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) - } + customTemplates = authorization.DecorateCustomTemplates(customTemplates, resourceControls) - customTemplates = filterByType(customTemplates, templateTypes) + if !securityContext.IsAdmin { + user, err := tx.User().Read(securityContext.UserID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user information from the database", err) + } - if edge != nil { - customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { - return customTemplate.EdgeTemplate == *edge - }) - } + userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships) - for i := range customTemplates { - populateGitConfig(handler.DataStore, &customTemplates[i]) - } + customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) + } - return response.JSON(w, customTemplates) + customTemplates = filterByType(customTemplates, templateTypes) + + if edge != nil { + customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + return customTemplate.EdgeTemplate == *edge + }) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + for i := range customTemplates { + populateGitConfig(tx, userContext, &customTemplates[i]) + } + + return nil + }) + + return response.TxResponse(w, customTemplates, err) } func retrieveEdgeParam(r *http.Request) *bool { diff --git a/api/http/handler/customtemplates/customtemplate_list_test.go b/api/http/handler/customtemplates/customtemplate_list_test.go index 6cfaf617c..91f60eea3 100644 --- a/api/http/handler/customtemplates/customtemplate_list_test.go +++ b/api/http/handler/customtemplates/customtemplate_list_test.go @@ -28,7 +28,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) { TLSSkipVerify: true, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ @@ -48,7 +48,7 @@ func TestCustomTemplateList_PopulatesGitConfigFromSource(t *testing.T) { })) r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil) - r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})) rr := httptest.NewRecorder() handler.ServeHTTP(rr, r) @@ -97,7 +97,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) { }, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID require.NoError(t, tx.CustomTemplate().Create(&portainer.CustomTemplate{ @@ -111,7 +111,7 @@ func TestCustomTemplateList_StripsPasswordFromGitConfig(t *testing.T) { })) r := httptest.NewRequest(http.MethodGet, "/custom_templates", nil) - r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})) + r = r.WithContext(security.StoreRestrictedRequestContext(r, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true, User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}})) rr := httptest.NewRecorder() handler.ServeHTTP(rr, r) diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index 4af2f41c8..e8ca14cd3 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -9,6 +9,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git" gittypes "github.com/portainer/portainer/api/git/types" @@ -182,8 +183,10 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ customTemplate.IsComposeFormat = payload.IsComposeFormat customTemplate.EdgeTemplate = payload.EdgeTemplate + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if payload.SourceID != 0 || payload.RepositoryURL != "" { - gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, sources.RepoConfigInput{ + gitConfig, httpErr := sources.ResolveRepoConfig(handler.DataStore, userContext, sources.RepoConfigInput{ SourceID: payload.SourceID, ReferenceName: payload.RepositoryReferenceName, ConfigFilePath: payload.ComposeFilePathInRepository, @@ -231,7 +234,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ sourceID := payload.SourceID if sourceID == 0 { - src, err := workflows.FindOrCreateGitSource(handler.DataStore, &portainer.Source{ + src, err := workflows.FindOrCreateGitSource(handler.DataStore, userContext, &portainer.Source{ Name: gittypes.RepoName(gitConfig.URL), Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{ @@ -271,7 +274,8 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ return httperror.InternalServerError("Unable to persist custom template changes inside the database", err) } - populateGitConfig(tx, customTemplate) + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + populateGitConfig(tx, userContext, customTemplate) return nil }) diff --git a/api/http/handler/customtemplates/customtemplate_update_test.go b/api/http/handler/customtemplates/customtemplate_update_test.go index f8e84ec43..8f9279bad 100644 --- a/api/http/handler/customtemplates/customtemplate_update_test.go +++ b/api/http/handler/customtemplates/customtemplate_update_test.go @@ -27,6 +27,14 @@ func updateTemplateRequest(t *testing.T, templateID string, payload any, ctx *se r.Header.Set("Content-Type", "application/json") r = mux.SetURLVars(r, map[string]string{"id": templateID}) + if ctx.User == nil { + role := portainer.StandardUserRole + if ctx.IsAdmin { + role = portainer.AdministratorRole + } + ctx.User = &portainer.User{ID: ctx.UserID, Role: role} + } + return r.WithContext(security.StoreRestrictedRequestContext(r, ctx)) } @@ -476,7 +484,7 @@ func TestCustomTemplateUpdate_WithSourceID_Success(t *testing.T) { URL: "https://github.com/example/repo", }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID return nil @@ -630,7 +638,7 @@ func TestCustomTemplateUpdate_GitRepository_Success(t *testing.T) { require.NoError(t, err) require.NotNil(t, stored.Artifact) - src, err := tx.Source().Read(stored.Artifact.Files[0].SourceID) + src, err := tx.Source().Read(adminUserContext, stored.Artifact.Files[0].SourceID) require.NoError(t, err) require.Equal(t, portainer.SourceTypeGit, src.Type) require.Equal(t, "https://github.com/example/repo", src.Git.URL) diff --git a/api/http/handler/customtemplates/helpers_test.go b/api/http/handler/customtemplates/helpers_test.go new file mode 100644 index 000000000..e93864422 --- /dev/null +++ b/api/http/handler/customtemplates/helpers_test.go @@ -0,0 +1,5 @@ +package customtemplates + +import "github.com/portainer/portainer/api/dataservices/source" + +var adminUserContext = source.InsecureNewAdminContext() diff --git a/api/http/handler/customtemplates/utils.go b/api/http/handler/customtemplates/utils.go index 721bfabe5..ffaabbdf6 100644 --- a/api/http/handler/customtemplates/utils.go +++ b/api/http/handler/customtemplates/utils.go @@ -8,14 +8,14 @@ import ( "github.com/portainer/portainer/api/dataservices" ) -func populateGitConfig(tx dataservices.DataStoreTx, template *portainer.CustomTemplate) { +func populateGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, template *portainer.CustomTemplate) { if template.Artifact == nil || len(template.Artifact.Files) == 0 { return } file := template.Artifact.Files[0] - src, err := tx.Source().Read(file.SourceID) + src, err := tx.Source().Read(userContext, file.SourceID) if err != nil || src.Git == nil { return } diff --git a/api/http/handler/customtemplates/utils_test.go b/api/http/handler/customtemplates/utils_test.go index 399a4faaf..c64ec221d 100644 --- a/api/http/handler/customtemplates/utils_test.go +++ b/api/http/handler/customtemplates/utils_test.go @@ -19,7 +19,8 @@ func TestPopulateGitConfig_NilArtifactIsNoOp(t *testing.T) { template := &portainer.CustomTemplate{ID: 1} err := store.ViewTx(func(tx dataservices.DataStoreTx) error { - populateGitConfig(tx, template) + + populateGitConfig(tx, adminUserContext, template) return nil }) @@ -40,39 +41,7 @@ func TestPopulateGitConfig_EmptySourceIDsIsNoOp(t *testing.T) { } err := store.ViewTx(func(tx dataservices.DataStoreTx) error { - populateGitConfig(tx, template) - - return nil - }) - require.NoError(t, err) - require.Nil(t, template.GitConfig) -} - -func TestPopulateGitConfig_SourceWithNilGitConfigIsNoOp(t *testing.T) { - t.Parallel() - - _, store := datastore.MustNewTestStore(t, false, true) - - var srcID portainer.SourceID - err := store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) - require.NoError(t, err) - srcID = src.ID - - return nil - }) - require.NoError(t, err) - - template := &portainer.CustomTemplate{ - ID: 1, - Artifact: &portainer.Artifact{ - Files: []portainer.ArtifactFile{{SourceID: srcID}}, - }, - } - - err = store.ViewTx(func(tx dataservices.DataStoreTx) error { - populateGitConfig(tx, template) + populateGitConfig(tx, adminUserContext, template) return nil }) @@ -94,7 +63,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) { TLSSkipVerify: true, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -115,7 +84,7 @@ func TestPopulateGitConfig_PopulatesFromSourceAndArtifact(t *testing.T) { } err = store.ViewTx(func(tx dataservices.DataStoreTx) error { - populateGitConfig(tx, template) + populateGitConfig(tx, adminUserContext, template) return nil }) @@ -145,7 +114,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) { }, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -161,7 +130,7 @@ func TestPopulateGitConfig_StripsPassword(t *testing.T) { } err = store.ViewTx(func(tx dataservices.DataStoreTx) error { - populateGitConfig(tx, template) + populateGitConfig(tx, adminUserContext, template) return nil }) diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go index 83901a298..0ea3a6674 100644 --- a/api/http/handler/edgestacks/edgestack_create_git.go +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -7,12 +7,15 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/sources" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/pkg/edge" + httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/validate" @@ -124,7 +127,13 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat return stack, nil } - repoConfig, httpErr := sources.ResolveRepoConfig(tx, sources.RepoConfigInput{ + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return nil, httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + repoConfig, httpErr := sources.ResolveRepoConfig(tx, userContext, sources.RepoConfigInput{ SourceID: payload.SourceID, ReferenceName: payload.RepositoryReferenceName, ConfigFilePath: payload.FilePathInRepository, diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go index 93545a80e..dab2f3d8c 100644 --- a/api/http/handler/gitops/git_repo_file_preview.go +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -7,8 +7,10 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -87,8 +89,14 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht password := payload.Password tlsSkipVerify := payload.TLSSkipVerify + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if payload.SourceID != 0 { - src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, payload.SourceID) + src, httpErr := sources.ValidateGitSourceAccess(handler.dataStore, userContext, payload.SourceID) if httpErr != nil { return httpErr } diff --git a/api/http/handler/gitops/sources/create_git.go b/api/http/handler/gitops/sources/create_git.go index 6329e377e..74b868120 100644 --- a/api/http/handler/gitops/sources/create_git.go +++ b/api/http/handler/gitops/sources/create_git.go @@ -7,8 +7,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" - "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -21,8 +22,16 @@ type GitAuthenticationPayload struct { Password string `json:"password"` } +type SourceAccessControlPayload struct { + Public bool `json:"public" example:"true"` + AdministratorsOnly bool `json:"administratorsOnly" example:"true"` + UserAccesses []portainer.UserID `json:"userAccesses"` + TeamAccesses []portainer.TeamID `json:"teamAccesses"` +} + // GitSourceCreatePayload holds the parameters for creating a git-backed source type GitSourceCreatePayload struct { + SourceAccessControlPayload Name string `json:"name"` URL string `json:"url" validate:"required"` TLSSkipVerify bool `json:"tlsSkipVerify"` @@ -41,7 +50,7 @@ func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error { // @id GitOpsSourcesCreateGit // @summary Create a Git source // @description Creates a new GitOps source backed by a Git repository. -// @description **Access policy**: administrator +// @description **Access policy**: authenticated // @tags gitops // @security ApiKeyAuth // @security jwt @@ -61,26 +70,20 @@ func (h *Handler) gitSourceCreate(w http.ResponseWriter, r *http.Request) *httpe return httperror.BadRequest("Invalid request payload", err) } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + src, err := BuildGitSource(payload) if err != nil { return httperror.BadRequest("Invalid request payload", err) } - username, password := "", "" - if payload.Authentication != nil { - username = payload.Authentication.Username - password = payload.Authentication.Password - } - if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - if isUnique, err := workflows.ValidateUniqueSource(tx, payload.URL, username, password, 0); err != nil { - return err - } else if !isUnique { - return ErrDuplicateSource - } - - return tx.Source().Create(src) - }); errors.Is(err, ErrDuplicateSource) { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + return tx.Source().Create(userContext, src) + }); errors.Is(err, source.ErrDuplicateSource) { return httperror.Conflict("A source with this URL and credentials already exists", err) } else if err != nil { return httperror.InternalServerError("Unable to create source", err) @@ -99,8 +102,7 @@ func BuildGitSource(payload GitSourceCreatePayload) (*portainer.Source, error) { return src, nil } -// BuildBaseGitSource constructs the source skeleton (name, URL, TLS) without -// authentication. +// BuildBaseGitSource constructs the source skeleton (name, URL, TLS, accesses) without authentication. func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source { name := payload.Name if strings.TrimSpace(name) == "" { @@ -114,6 +116,10 @@ func BuildBaseGitSource(payload GitSourceCreatePayload) *portainer.Source { URL: payload.URL, TLSSkipVerify: payload.TLSSkipVerify, }, + UserAccesses: payload.UserAccesses, + TeamAccesses: payload.TeamAccesses, + Public: payload.Public, + AdministratorsOnly: payload.AdministratorsOnly, } } diff --git a/api/http/handler/gitops/sources/create_git_test.go b/api/http/handler/gitops/sources/create_git_test.go index 7ca776843..6f59d9eb7 100644 --- a/api/http/handler/gitops/sources/create_git_test.go +++ b/api/http/handler/gitops/sources/create_git_test.go @@ -97,7 +97,7 @@ func TestGitSourceCreate_Success(t *testing.T) { require.Equal(t, portainer.SourceTypeGit, src.Type) require.NotZero(t, src.ID) require.NotNil(t, src.Git) - require.Equal(t, "https://github.com/org/repo.git", src.Git.URL) + require.Equal(t, "https://github.com/org/repo", src.Git.URL) } func TestGitSourceCreate_SanitizesCredentials(t *testing.T) { diff --git a/api/http/handler/gitops/sources/delete.go b/api/http/handler/gitops/sources/delete.go index b2620a749..14c883629 100644 --- a/api/http/handler/gitops/sources/delete.go +++ b/api/http/handler/gitops/sources/delete.go @@ -8,6 +8,8 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" dserrors "github.com/portainer/portainer/api/dataservices/errors" + "github.com/portainer/portainer/api/dataservices/source" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -18,7 +20,7 @@ var ErrSourceInUse = errors.New("source is used by one or more workflows or cust // @id GitOpsSourcesDelete // @summary Delete a source // @description Deletes an existing GitOps source. Returns 409 if the source is referenced by any workflow or custom template. -// @description **Access policy**: admin +// @description **Access policy**: authenticated // @tags gitops // @security ApiKeyAuth // @security jwt @@ -36,8 +38,15 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro return httperror.BadRequest("Invalid source identifier route variable", err) } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - if exists, err := tx.Source().Exists(portainer.SourceID(sourceID)); err != nil { + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if exists, err := tx.Source().Exists(userContext, portainer.SourceID(sourceID)); err != nil { return err } else if !exists { return dserrors.ErrObjectNotFound @@ -71,11 +80,13 @@ func (h *Handler) sourceDelete(w http.ResponseWriter, r *http.Request) *httperro return ErrSourceInUse } - return tx.Source().Delete(portainer.SourceID(sourceID)) + return tx.Source().Delete(userContext, portainer.SourceID(sourceID)) }); h.dataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a source with the specified identifier", err) } else if errors.Is(err, ErrSourceInUse) { return httperror.Conflict("Source is used by one or more workflows or custom templates", err) + } else if errors.Is(err, source.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to delete source", err) } else if err != nil { return httperror.InternalServerError("Unable to delete source", err) } diff --git a/api/http/handler/gitops/sources/delete_test.go b/api/http/handler/gitops/sources/delete_test.go index 49ba8fac5..27fb8ae22 100644 --- a/api/http/handler/gitops/sources/delete_test.go +++ b/api/http/handler/gitops/sources/delete_test.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/stretchr/testify/require" ) @@ -19,8 +20,8 @@ func TestSourceDelete_Success(t *testing.T) { var srcID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) + src := &portainer.Source{Name: "to-delete", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -57,8 +58,8 @@ func TestSourceDelete_InUse(t *testing.T) { var srcID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) + src := &portainer.Source{Name: "in-use", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -99,8 +100,8 @@ func TestSourceDelete_InUseByCustomTemplate(t *testing.T) { var srcID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) + src := &portainer.Source{Name: "in-use-by-template", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID diff --git a/api/http/handler/gitops/sources/get.go b/api/http/handler/gitops/sources/get.go index 8af706be6..39ac33d4e 100644 --- a/api/http/handler/gitops/sources/get.go +++ b/api/http/handler/gitops/sources/get.go @@ -1,12 +1,15 @@ package sources import ( + "errors" "net/http" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + sourceDS "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -26,12 +29,19 @@ type AutoUpdateInfo struct { FetchInterval string `json:"fetchInterval,omitempty"` } +type SourceAccess struct { + Public bool `json:"public,omitempty"` + Users []portainer.UserID `json:"users,omitempty"` + Teams []portainer.TeamID `json:"teams,omitempty"` +} + // SourceDetail extends Source with connection settings and linked workflows. type SourceDetail struct { Source Connection connectionInfo `json:"connection" validate:"required"` AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"` Workflows []workflows.Workflow `json:"workflows"` + Access SourceAccess `json:"access"` } // @id GitOpsSourceGet @@ -55,6 +65,11 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H return httperror.BadRequest("Invalid source identifier route variable", err) } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + sourceID := portainer.SourceID(srcID) var source *portainer.Source @@ -63,7 +78,8 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { var err error - source, err = tx.Source().Read(sourceID) + userContext := sourceDS.NewUserContext(securityContext.User, securityContext.UserMemberships) + source, err = tx.Source().Read(userContext, sourceID) if err != nil { return err } @@ -74,15 +90,19 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H if h.dataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Source not found", err) + } else if errors.Is(err, sourceDS.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to retrieve source", err) } else if err != nil { return httperror.InternalServerError("Unable to retrieve source", err) } - detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs) + access := BuildSourceAccess(source) + + detail := BuildSourceDetail(h.buildSource(r.Context(), source, stats), source.Git, sourceWfs, access) return response.JSON(w, detail) } -func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow) SourceDetail { +func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs []workflows.Workflow, access SourceAccess) SourceDetail { var autoUpdate *AutoUpdateInfo if len(sourceWfs) > 0 { autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate) @@ -93,6 +113,29 @@ func BuildSourceDetail(baseSource Source, cfg *gittypes.RepoConfig, sourceWfs [] Connection: buildConnectionInfo(cfg), AutoUpdate: autoUpdate, Workflows: redactWorkflowCredentials(sourceWfs), + Access: access, + } +} + +func BuildSourceAccess(source *portainer.Source) SourceAccess { + if source == nil { + return SourceAccess{} + } + + if source.AdministratorsOnly { + return SourceAccess{} + } + + if source.Public { + return SourceAccess{ + Public: true, + } + } + + return SourceAccess{ + Public: source.Public, + Users: source.UserAccesses, + Teams: source.TeamAccesses, } } diff --git a/api/http/handler/gitops/sources/handler.go b/api/http/handler/gitops/sources/handler.go index 7b6d8632f..4d93e74ff 100644 --- a/api/http/handler/gitops/sources/handler.go +++ b/api/http/handler/gitops/sources/handler.go @@ -42,13 +42,15 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) authenticatedRouter.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.getSource)).Methods(http.MethodGet) + authenticatedRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost) + authenticatedRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost) + authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut) + authenticatedRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete) + authenticatedRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost) adminRouter := h.PathPrefix("/gitops/sources").Subrouter() adminRouter.Use(bouncer.AdminAccess) - adminRouter.Handle("/git", httperror.LoggerHandler(h.gitSourceCreate)).Methods(http.MethodPost) - adminRouter.Handle("/test", httperror.LoggerHandler(h.gitSourceTest)).Methods(http.MethodPost) - adminRouter.Handle("/{id}", httperror.LoggerHandler(h.gitSourceUpdate)).Methods(http.MethodPut) - adminRouter.Handle("/{id}", httperror.LoggerHandler(h.sourceDelete)).Methods(http.MethodDelete) - adminRouter.Handle("/{id}/test", httperror.LoggerHandler(h.sourceTestConnection)).Methods(http.MethodPost) + adminRouter.Handle("/{id}/access", httperror.LoggerHandler(h.gitSourceUpdateAccess)).Methods(http.MethodPut) + return h } diff --git a/api/http/handler/gitops/sources/helpers_test.go b/api/http/handler/gitops/sources/helpers_test.go index 028716ad3..b4765e1aa 100644 --- a/api/http/handler/gitops/sources/helpers_test.go +++ b/api/http/handler/gitops/sources/helpers_test.go @@ -9,6 +9,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/testhelpers" @@ -17,6 +18,8 @@ import ( "github.com/stretchr/testify/require" ) +var adminUserContext = source.InsecureNewAdminContext() + // createGitWorkflow creates a Source and Workflow for the given config and // wires them up by setting stack.WorkflowID before creating the stack. func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.Stack, cfg *gittypes.RepoConfig) portainer.SourceID { @@ -31,7 +34,7 @@ func createGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *portain TLSSkipVerify: cfg.TLSSkipVerify, }, } - require.NoError(t, tx.Source().Create(src)) + require.NoError(t, tx.Source().Create(adminUserContext, src)) wf := &portainer.Workflow{ Artifacts: []portainer.Artifact{{ @@ -55,13 +58,19 @@ func newTestHandler(t *testing.T, store dataservices.DataStore) *Handler { return NewHandler(testhelpers.NewTestRequestBouncer(), store, nil, nil) } +func adminRestrictedContext(userID portainer.UserID) *security.RestrictedRequestContext { + return &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: true, + User: &portainer.User{ID: userID, Role: portainer.AdministratorRole}, + } +} + func buildListReq(t *testing.T, userID portainer.UserID, query string) *http.Request { t.Helper() req := httptest.NewRequest(http.MethodGet, "/gitops/sources?"+query, nil) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -69,9 +78,7 @@ func buildGetReq(t *testing.T, userID portainer.UserID, id string) *http.Request t.Helper() req := httptest.NewRequest(http.MethodGet, "/gitops/sources/"+id, nil) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -104,9 +111,7 @@ func buildCreateReq(t *testing.T, userID portainer.UserID, body []byte) *http.Re req := httptest.NewRequest(http.MethodPost, "/gitops/sources/git", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -115,9 +120,7 @@ func buildUpdateReq(t *testing.T, userID portainer.UserID, id int, body []byte) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/gitops/sources/%d", id), bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -125,9 +128,7 @@ func buildDeleteReq(t *testing.T, userID portainer.UserID, id int) *http.Request t.Helper() req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/gitops/sources/%d", id), nil) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -135,9 +136,7 @@ func buildSummaryReq(t *testing.T, userID portainer.UserID) *http.Request { t.Helper() req := httptest.NewRequest(http.MethodGet, "/gitops/sources/summary", nil) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -146,9 +145,7 @@ func buildUpdateReqWithRawID(t *testing.T, userID portainer.UserID, id string, b req := httptest.NewRequest(http.MethodPut, "/gitops/sources/"+id, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } @@ -156,8 +153,6 @@ func buildDeleteReqWithRawID(t *testing.T, userID portainer.UserID, id string) * t.Helper() req := httptest.NewRequest(http.MethodDelete, "/gitops/sources/"+id, nil) req = req.WithContext(security.StoreTokenData(req, &portainer.TokenData{ID: userID})) - req = req.WithContext(security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ - UserID: userID, IsAdmin: true, - })) + req = req.WithContext(security.StoreRestrictedRequestContext(req, adminRestrictedContext(userID))) return req } diff --git a/api/http/handler/gitops/sources/list.go b/api/http/handler/gitops/sources/list.go index d4eece453..197eab688 100644 --- a/api/http/handler/gitops/sources/list.go +++ b/api/http/handler/gitops/sources/list.go @@ -9,7 +9,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/utils/filters" "github.com/portainer/portainer/api/slicesx" @@ -56,7 +56,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle } if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" { - s, err := ceWorkflows.ParseStatus(status) + s, err := workflows.ParseStatus(status) if err != nil { return httperror.BadRequest("Invalid status parameter", err) } @@ -111,11 +111,11 @@ func cacheKey(sc *security.RestrictedRequestContext) string { func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) { var allSrcs []portainer.Source - var stats map[portainer.SourceID]ceWorkflows.SourceStats + var stats map[portainer.SourceID]workflows.SourceStats if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { var err error - allSrcs, stats, err = ceWorkflows.FetchSourceStats(tx, h.k8sFactory, sc) + allSrcs, stats, err = workflows.FetchSourceStats(tx, h.k8sFactory, sc) return err }); err != nil { return nil, err @@ -123,12 +123,12 @@ func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedReque result := make([]Source, 0, len(allSrcs)) for _, src := range allSrcs { - s, accessible := stats[src.ID] - if !accessible && !sc.IsAdmin { - continue + stat, ok := stats[src.ID] + if !ok { + stat = workflows.SourceStats{} } - result = append(result, h.buildSource(ctx, &src, s)) + result = append(result, h.buildSource(ctx, &src, stat)) } return result, nil diff --git a/api/http/handler/gitops/sources/list_test.go b/api/http/handler/gitops/sources/list_test.go index 7d8c40117..2d1bff4f2 100644 --- a/api/http/handler/gitops/sources/list_test.go +++ b/api/http/handler/gitops/sources/list_test.go @@ -20,7 +20,7 @@ func TestSourcesList_GroupsByURLAndCredentials(t *testing.T) { require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { cfg := gitCfg("https://github.com/org/repo") src := &portainer.Source{Name: "repo", Type: portainer.SourceTypeGit, Git: cfg} - require.NoError(t, tx.Source().Create(src)) + require.NoError(t, tx.Source().Create(adminUserContext, src)) wfA := &portainer.Workflow{Artifacts: []portainer.Artifact{{Files: []portainer.ArtifactFile{{SourceID: src.ID}}}}} require.NoError(t, tx.Workflow().Create(wfA)) diff --git a/api/http/handler/gitops/sources/source_connection.go b/api/http/handler/gitops/sources/source_connection.go index 0d77f085f..c98ea257b 100644 --- a/api/http/handler/gitops/sources/source_connection.go +++ b/api/http/handler/gitops/sources/source_connection.go @@ -8,7 +8,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -17,7 +19,7 @@ import ( // @id GitOpsSourcesTestById // @summary Test the connection of a stored source // @description Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. -// @description **Access policy**: administrator +// @description **Access policy**: authenticated // @tags gitops // @security ApiKeyAuth // @security jwt @@ -37,6 +39,11 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) * return httperror.BadRequest("Invalid source identifier route variable", err) } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + var payload GitSourceUpdatePayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil && !errors.Is(err, io.EOF) { return httperror.BadRequest("Invalid request payload", err) @@ -44,10 +51,13 @@ func (h *Handler) sourceTestConnection(w http.ResponseWriter, r *http.Request) * var src *portainer.Source if err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { - src, err = tx.Source().Read(portainer.SourceID(sourceID)) + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + src, err = tx.Source().Read(userContext, portainer.SourceID(sourceID)) return err }); h.dataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a source with the specified identifier", err) + } else if errors.Is(err, source.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to retrieve source", err) } else if err != nil { return httperror.InternalServerError("Unable to find source", err) } @@ -75,7 +85,7 @@ type ConnectionTestResult struct { // @id GitOpsSourcesTest // @summary Test a Git source connection // @description Tests connectivity for Git connection details that have not been persisted yet. -// @description **Access policy**: administrator +// @description **Access policy**: authenticated // @tags gitops // @security ApiKeyAuth // @security jwt diff --git a/api/http/handler/gitops/sources/summary_test.go b/api/http/handler/gitops/sources/summary_test.go index ba4088a61..14bd5542d 100644 --- a/api/http/handler/gitops/sources/summary_test.go +++ b/api/http/handler/gitops/sources/summary_test.go @@ -1,6 +1,7 @@ package sources import ( + "fmt" "net/http" "net/http/httptest" "testing" @@ -8,6 +9,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" + gittypes "github.com/portainer/portainer/api/git/types" ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" "github.com/segmentio/encoding/json" @@ -38,10 +40,9 @@ func TestSourcesSummary_CountsByStatus(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) - // With nil gitService and nil GitConfig, all sources get StatusUnknown. require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - for _, name := range []string{"source-a", "source-b", "source-c"} { - err := tx.Source().Create(&portainer.Source{Name: name, Type: portainer.SourceTypeGit}) + for idx, name := range []string{"source-a", "source-b", "source-c"} { + err := tx.Source().Create(adminUserContext, &portainer.Source{Name: name, Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: fmt.Sprintf("http://github.com/org/repo%d", idx)}}) require.NoError(t, err) } diff --git a/api/http/handler/gitops/sources/update_access.go b/api/http/handler/gitops/sources/update_access.go new file mode 100644 index 000000000..34c01e54a --- /dev/null +++ b/api/http/handler/gitops/sources/update_access.go @@ -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 + } +} diff --git a/api/http/handler/gitops/sources/update_git.go b/api/http/handler/gitops/sources/update_git.go index 4731c9d71..983ee3e54 100644 --- a/api/http/handler/gitops/sources/update_git.go +++ b/api/http/handler/gitops/sources/update_git.go @@ -7,8 +7,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" - "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -16,8 +17,7 @@ import ( ) var ( - ErrNotGitSource = errors.New("source is not a Git source") - ErrDuplicateSource = errors.New("a source with this URL and credentials already exists") + ErrNotGitSource = errors.New("source is not a Git source") ) // GitSourceUpdatePayload holds the parameters for creating a git-backed source @@ -46,7 +46,7 @@ func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error { // @id GitOpsSourcesUpdateGit // @summary Update a Git source // @description Updates an existing GitOps source backed by a Git repository. -// @description **Access policy**: administrator +// @description **Access policy**: authenticated // @tags gitops // @security ApiKeyAuth // @security jwt @@ -73,6 +73,11 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe return httperror.BadRequest("Invalid request payload", err) } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + sourceID := portainer.SourceID(id) var src *portainer.Source @@ -80,7 +85,8 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe if err := h.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { var err error - if src, err = tx.Source().Read(sourceID); err != nil { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if src, err = tx.Source().Read(userContext, sourceID); err != nil { return err } @@ -88,24 +94,14 @@ func (h *Handler) gitSourceUpdate(w http.ResponseWriter, r *http.Request) *httpe return err } - username, password := "", "" - if src.Git != nil && src.Git.Authentication != nil { - username = src.Git.Authentication.Username - password = src.Git.Authentication.Password - } - - if isUnique, err := workflows.ValidateUniqueSource(tx, src.Git.URL, username, password, sourceID); err != nil { - return err - } else if !isUnique { - return ErrDuplicateSource - } - - return tx.Source().Update(src.ID, src) + return tx.Source().Update(userContext, src.ID, src) }); h.dataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a source with the specified identifier", err) } else if errors.Is(err, ErrNotGitSource) { return httperror.BadRequest("Source is not a Git source", err) - } else if errors.Is(err, ErrDuplicateSource) { + } else if errors.Is(err, source.ErrNotEnoughPermission) { + return httperror.Forbidden("Not enough permissions to update source", err) + } else if errors.Is(err, source.ErrDuplicateSource) { return httperror.Conflict("A source with this URL and credentials already exists", err) } else if err != nil { return httperror.InternalServerError("Unable to update source", err) diff --git a/api/http/handler/gitops/sources/update_git_test.go b/api/http/handler/gitops/sources/update_git_test.go index b5d0c7bb0..684fefa48 100644 --- a/api/http/handler/gitops/sources/update_git_test.go +++ b/api/http/handler/gitops/sources/update_git_test.go @@ -20,8 +20,8 @@ func TestGitSourceUpdate_Success(t *testing.T) { var srcID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) + src := &portainer.Source{Name: "old-name", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -46,7 +46,7 @@ func TestGitSourceUpdate_Success(t *testing.T) { require.NoError(t, err) require.Equal(t, "new-name", src.Name) require.NotNil(t, src.Git) - require.Equal(t, "https://github.com/org/new.git", src.Git.URL) + require.Equal(t, "https://github.com/org/new", src.Git.URL) } func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { @@ -66,7 +66,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { }, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -89,7 +89,7 @@ func TestGitSourceUpdate_PreservesAuthWhenNotProvided(t *testing.T) { var stored *portainer.Source require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { var err error - stored, err = tx.Source().Read(srcID) + stored, err = tx.Source().Read(adminUserContext, srcID) return err })) require.NotNil(t, stored.Git) @@ -115,7 +115,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) { }, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -138,7 +138,7 @@ func TestGitSourceUpdate_ClearsAuthWhenRequested(t *testing.T) { var stored *portainer.Source require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { var err error - stored, err = tx.Source().Read(srcID) + stored, err = tx.Source().Read(adminUserContext, srcID) return err })) require.NotNil(t, stored.Git) @@ -162,7 +162,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) { }, }, } - err := tx.Source().Create(src) + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -188,7 +188,7 @@ func TestGitSourceUpdate_ReplacesAuthWhenProvided(t *testing.T) { var stored *portainer.Source require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error { var err error - stored, err = tx.Source().Read(srcID) + stored, err = tx.Source().Read(adminUserContext, srcID) return err })) require.NotNil(t, stored.Git) @@ -229,11 +229,11 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) { URL: "https://github.com/org/existing.git", }, } - err := tx.Source().Create(existing) + err := tx.Source().Create(adminUserContext, existing) require.NoError(t, err) - src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit} - err = tx.Source().Create(src) + src := &portainer.Source{Name: "other", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + err = tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -253,39 +253,14 @@ func TestGitSourceUpdate_ConflictOnDuplicateURL(t *testing.T) { require.Equal(t, http.StatusConflict, rr.Code) } -func TestGitSourceUpdate_NotGitSource(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - var srcID portainer.SourceID - require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "helm-source", Type: portainer.SourceTypeHelm} - err := tx.Source().Create(src) - require.NoError(t, err) - srcID = src.ID - - return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}) - })) - - h := newTestHandler(t, store) - - body, err := json.Marshal(GitSourceUpdatePayload{URL: new("https://github.com/org/repo.git")}) - require.NoError(t, err) - - rr := httptest.NewRecorder() - h.ServeHTTP(rr, buildUpdateReq(t, 1, int(srcID), body)) - - require.Equal(t, http.StatusBadRequest, rr.Code) -} - func TestGitSourceUpdate_MalformedJSON(t *testing.T) { t.Parallel() _, store := datastore.MustNewTestStore(t, false, true) var srcID portainer.SourceID require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { - src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit} - err := tx.Source().Create(src) + src := &portainer.Source{Name: "src", Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "http://github.com/org/repo"}} + err := tx.Source().Create(adminUserContext, src) require.NoError(t, err) srcID = src.ID @@ -317,7 +292,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T) }, }, } - if err := tx.Source().Create(existing); err != nil { + if err := tx.Source().Create(adminUserContext, existing); err != nil { return err } @@ -326,7 +301,7 @@ func TestGitSourceUpdate_ConflictWhenAuthChangesMatchAnotherSource(t *testing.T) Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/org/repo.git"}, } - if err := tx.Source().Create(other); err != nil { + if err := tx.Source().Create(adminUserContext, other); err != nil { return err } srcID = other.ID diff --git a/api/http/handler/gitops/workflows/helpers_test.go b/api/http/handler/gitops/workflows/helpers_test.go index 73b53dc8e..774bf0be2 100644 --- a/api/http/handler/gitops/workflows/helpers_test.go +++ b/api/http/handler/gitops/workflows/helpers_test.go @@ -7,6 +7,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" ce "github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/http/security" @@ -24,6 +25,7 @@ func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.Use ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ UserID: userID, IsAdmin: security.IsAdminRole(role), + User: &portainer.User{ID: userID, Role: role}, }) return req.WithContext(ctx) } @@ -48,7 +50,7 @@ func createGitStack(t *testing.T, tx dataservices.DataStoreTx, stack *portainer. if stack.GitConfig != nil { src := &portainer.Source{Git: stack.GitConfig, Type: portainer.SourceTypeGit} - require.NoError(t, tx.Source().Create(src)) + require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src)) wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ StackID: stack.ID, diff --git a/api/http/handler/gitops/workflows/list_test.go b/api/http/handler/gitops/workflows/list_test.go index fd37aba33..59bc5302e 100644 --- a/api/http/handler/gitops/workflows/list_test.go +++ b/api/http/handler/gitops/workflows/list_test.go @@ -81,7 +81,7 @@ func TestWorkflowsList_Pagination(t *testing.T) { createGitStack(t, tx, &portainer.Stack{ ID: portainer.StackID(i), Name: fmt.Sprintf("stack-%d", i), - GitConfig: gitConfig("https://github.com/x/y"), + GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/y-%d", i)), }) } diff --git a/api/http/handler/gitops/workflows/summary_test.go b/api/http/handler/gitops/workflows/summary_test.go index 24c1b4572..8a9f0d71c 100644 --- a/api/http/handler/gitops/workflows/summary_test.go +++ b/api/http/handler/gitops/workflows/summary_test.go @@ -23,6 +23,7 @@ func buildSummaryReq(t *testing.T, userID portainer.UserID, role portainer.UserR ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ UserID: userID, IsAdmin: security.IsAdminRole(role), + User: &portainer.User{ID: userID, Role: role}, }) return req.WithContext(ctx) } diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 4c896ba6c..46d5153e5 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -6,6 +6,7 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/gitops/sources" @@ -52,7 +53,7 @@ func createStackPayloadFromComposeFileContentPayload(name string, fileContent st } } -func (handler *Handler) checkAndCleanStackDupFromSwarm(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID, stack *portainer.Stack) error { +func (handler *Handler) checkAndCleanStackDupFromSwarm(_ http.ResponseWriter, _ *http.Request, _ *portainer.Endpoint, _ portainer.UserID, stack *portainer.Stack) error { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if err != nil { return err @@ -279,15 +280,16 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } } - if payload.SourceID != 0 { - if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { - return httpErr - } - } - securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + + if payload.SourceID != 0 { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil { + return httpErr + } } stackPayload := createStackPayloadFromComposeGitPayload(payload.Name, diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index d3daada50..48766e880 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -5,8 +5,10 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/gitops/sources" + "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/stacks/stackbuilders" @@ -234,8 +236,13 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr } } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) if payload.SourceID != 0 { - if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil { return httpErr } } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 2d08cffb7..127e637c6 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -5,6 +5,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/gitops/sources" "github.com/portainer/portainer/api/http/security" @@ -218,15 +219,15 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, } } - if payload.SourceID != 0 { - if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID); httpErr != nil { - return httpErr - } - } - securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) + return httperror.InternalServerError("Unable to retrieve user info from request context", err) + } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if payload.SourceID != 0 { + if _, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID); httpErr != nil { + return httpErr + } } stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name, diff --git a/api/http/handler/stacks/response.go b/api/http/handler/stacks/response.go index e5cdbe981..02f934f03 100644 --- a/api/http/handler/stacks/response.go +++ b/api/http/handler/stacks/response.go @@ -15,8 +15,8 @@ type stackResponse struct { // loadGitConfigForStack reads the merged GitConfig (Source URL/auth/TLS + Artifact ref/path/hash) // and the SourceID for the given stack. -func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) { - src, file, err := workflows.GitSourceAndArtifactForStack(tx, workflowID, stackID) +func loadGitConfigForStack(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID) (*gittypes.RepoConfig, portainer.SourceID, error) { + src, file, err := workflows.GitSourceAndArtifactForStack(tx, userContext, workflowID, stackID) if err != nil || src == nil { return nil, 0, err } @@ -27,7 +27,7 @@ func loadGitConfigForStack(tx dataservices.DataStoreTx, workflowID portainer.Wor // saveStackGitConfig persists the stack's git settings. When newSourceID is non-zero the stack's // artifact is repointed to that existing Source (selected by the caller) without modifying any // Source's git config; otherwise the target Source is derived from cfg.URL. -func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { +func saveStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, workflowID portainer.WorkflowID, stackID portainer.StackID, oldSourceID, newSourceID portainer.SourceID, cfg *gittypes.RepoConfig) error { matchArtifact := func(a portainer.Artifact) bool { return a.StackID == stackID } @@ -41,16 +41,16 @@ func saveStackGitConfig(tx dataservices.DataStoreTx, workflowID portainer.Workfl }) } - return workflows.SaveWorkflowGitConfig(tx, workflowID, matchArtifact, oldSourceID, cfg) + return workflows.SaveWorkflowGitConfig(tx, userContext, workflowID, matchArtifact, oldSourceID, cfg) } // newStackResponse fills stack.GitConfig and returns a response that also includes GitSourceId. -func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*stackResponse, error) { +func newStackResponse(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) (*stackResponse, error) { if stack.WorkflowID == 0 { return &stackResponse{Stack: *stack}, nil } - gitConfig, gitSourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) + gitConfig, gitSourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) if err != nil { return nil, err } @@ -61,12 +61,12 @@ func newStackResponse(tx dataservices.DataStoreTx, stack *portainer.Stack) (*sta } // fillStackGitConfig populates stack.GitConfig from the merged Source+Artifact for backwards-compatible responses. -func fillStackGitConfig(tx dataservices.DataStoreTx, stack *portainer.Stack) error { +func fillStackGitConfig(tx dataservices.DataStoreTx, userContext *dataservices.SourceServiceUserContext, stack *portainer.Stack) error { if stack.WorkflowID == 0 { return nil } - gitConfig, _, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) + gitConfig, _, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) if err != nil { return err } diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go index 3c7aba819..c9f739378 100644 --- a/api/http/handler/stacks/stack_associate.go +++ b/api/http/handler/stacks/stack_associate.go @@ -7,6 +7,8 @@ import ( "time" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/stackutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -120,9 +122,13 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) * stack.ResourceControl = resourceControl - if err := fillStackGitConfig(handler.DataStore, stack); err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) - } + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) - return response.JSON(w, stack) + return response.TxResponse(w, stack, err) } diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index df1864c93..fdc9e4575 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -4,6 +4,8 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/stacks/stackutils" @@ -126,16 +128,29 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port resourceControl = authorization.NewPrivateResourceControl(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl, userID) } - err = handler.DataStore.ResourceControl().Create(resourceControl) - if err != nil { - return httperror.InternalServerError("Unable to persist resource control inside the database", err) - } + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + err = tx.ResourceControl().Create(resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to persist resource control inside the database", err) + } + stack.ResourceControl = resourceControl - stack.ResourceControl = resourceControl + user, err := tx.User().Read(userID) + if err != nil { + return httperror.InternalServerError("Unable to read user", err) + } - if err := fillStackGitConfig(handler.DataStore, stack); err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) - } + userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) + if err != nil { + return httperror.InternalServerError("Unable to read user's team memberships", err) + } - return response.JSON(w, stack) + userContext := source.NewUserContext(user, userMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) + + return response.TxResponse(w, stack, err) } diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index b9e14fbc5..9af95d127 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -192,7 +192,7 @@ func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) if stackutils.IsRelativePathStack(stack) { - return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, stack, endpoint) + return handler.StackDeployer.UndeployRemoteSwarmStack(ctx, userID, stack, endpoint) } return handler.SwarmStackManager.Remove(ctx, stack, endpoint) @@ -202,7 +202,7 @@ func (handler *Handler) deleteStack(ctx context.Context, userID portainer.UserID stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) if stackutils.IsRelativePathStack(stack) { - return handler.StackDeployer.UndeployRemoteComposeStack(ctx, stack, endpoint) + return handler.StackDeployer.UndeployRemoteComposeStack(ctx, userID, stack, endpoint) } return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint) diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index 19519b993..4c59481d4 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -4,6 +4,8 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" @@ -96,10 +98,16 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe var gitConfig *gittypes.RepoConfig if stack.WorkflowID != 0 { - var err error - gitConfig, _, err = loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID) - if err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + var err error + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + gitConfig, _, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) } } diff --git a/api/http/handler/stacks/stack_file_test.go b/api/http/handler/stacks/stack_file_test.go index fe1afed45..a4b66c44b 100644 --- a/api/http/handler/stacks/stack_file_test.go +++ b/api/http/handler/stacks/stack_file_test.go @@ -8,6 +8,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" @@ -45,7 +46,7 @@ func TestStackFile_GitPendingRedeploy_Returns409(t *testing.T) { ConfigFilePath: "docker-compose.yml", }, } - require.NoError(t, store.Source().Create(src)) + require.NoError(t, store.Source().Create(source.InsecureNewAdminContext(), src)) wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ StackID: stackID, diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index 372354cc7..4217d3e93 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -4,6 +4,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/stackutils" @@ -91,7 +92,8 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht } } - resp, err := newStackResponse(handler.DataStore, stack) + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + resp, err := newStackResponse(handler.DataStore, userContext, stack) if err != nil { return httperror.InternalServerError("Unable to load git config for stack", err) } diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index 592d2e43d..0b8eccef4 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -4,6 +4,8 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -79,13 +81,17 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs) } - for i := range stacks { - if err := fillStackGitConfig(handler.DataStore, &stacks[i]); err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + for i := range stacks { + if err := fillStackGitConfig(tx, userContext, &stacks[i]); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } } - } + return nil + }) - return response.JSON(w, stacks) + return response.TxResponse(w, stacks, err) } // filterStacks refines a collection of Stack instances using specified criteria. diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 8d69e9e12..cccb24f6b 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -7,6 +7,8 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/deployments" @@ -172,11 +174,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht } } - if err := fillStackGitConfig(handler.DataStore, stack); err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) - } + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) - return response.JSON(w, stack) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + + return nil + }) + + return response.TxResponse(w, stack, err) } func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 1143e3140..ba26683b9 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -9,6 +9,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/deployments" @@ -136,7 +137,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http stack.AutoUpdate.JobID = jobID } - if err := handler.startStack(context.TODO(), stack, endpoint, securityContext); err != nil { + if err := handler.startStack(context.TODO(), securityContext.UserID, stack, endpoint, securityContext); err != nil { stack.Status = portainer.StackStatusError stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ Status: portainer.StackStatusError, @@ -156,21 +157,25 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http stack.DeploymentStatus = []portainer.StackDeploymentStatus{ {Status: portainer.StackStatusActive, Time: time.Now().Unix()}, } - if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - return tx.Stack().Update(stack.ID, stack) - }); err != nil { - return httperror.InternalServerError("Unable to update stack status", err) - } + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return httperror.InternalServerError("Unable to update stack status", err) + } - if err := fillStackGitConfig(handler.DataStore, stack); err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) - } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) - return response.JSON(w, stack) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) + + return response.TxResponse(w, stack, err) } func (handler *Handler) startStack( ctx context.Context, + userID portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, securityContext *security.RestrictedRequestContext, @@ -192,7 +197,7 @@ func (handler *Handler) startStack( stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) if stackutils.IsRelativePathStack(stack) { - return handler.StackDeployer.StartRemoteComposeStack(ctx, stack, endpoint, filteredRegistries) + return handler.StackDeployer.StartRemoteComposeStack(ctx, userID, stack, endpoint, filteredRegistries) } return handler.StackDeployer.DeployComposeStack(ctx, stack, endpoint, filteredRegistries, false, false, false) @@ -200,7 +205,7 @@ func (handler *Handler) startStack( stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) if stackutils.IsRelativePathStack(stack) { - return handler.StackDeployer.StartRemoteSwarmStack(ctx, stack, endpoint, filteredRegistries) + return handler.StackDeployer.StartRemoteSwarmStack(ctx, userID, stack, endpoint, filteredRegistries) } return handler.StackDeployer.DeploySwarmStack(ctx, stack, endpoint, filteredRegistries, true, true) diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index 447642db8..2ef1258fa 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -7,6 +7,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/deployments" @@ -108,7 +109,7 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe stack.AutoUpdate.JobID = "" } - stopErr := handler.stopStack(r.Context(), stack, endpoint) + stopErr := handler.stopStack(r.Context(), securityContext.UserID, stack, endpoint) if stopErr != nil { if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { stackutils.UpdateStackStatusFromUndeploymentResult(stack, stopErr) @@ -120,27 +121,29 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe return httperror.InternalServerError("Unable to stop stack", stopErr) } - if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { stackutils.UpdateStackStatusFromUndeploymentResult(stack, nil) - return tx.Stack().Update(stack.ID, stack) - }); err != nil { - return httperror.InternalServerError("Unable to update stack status", err) - } + if err := tx.Stack().Update(stack.ID, stack); err != nil { + return httperror.InternalServerError("Unable to update stack status", err) + } - if err := fillStackGitConfig(handler.DataStore, stack); err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) - } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + return nil + }) - return response.JSON(w, stack) + return response.TxResponse(w, stack, err) } -func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { +func (handler *Handler) stopStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error { switch stack.Type { case portainer.DockerComposeStack: stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) if stackutils.IsRelativePathStack(stack) { - return handler.StackDeployer.StopRemoteComposeStack(ctx, stack, endpoint) + return handler.StackDeployer.StopRemoteComposeStack(ctx, userId, stack, endpoint) } return handler.StackDeployer.UndeployComposeStack(ctx, stack, endpoint) @@ -148,7 +151,7 @@ func (handler *Handler) stopStack(ctx context.Context, stack *portainer.Stack, e stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) if stackutils.IsRelativePathStack(stack) { - return handler.StackDeployer.StopRemoteSwarmStack(ctx, stack, endpoint) + return handler.StackDeployer.StopRemoteSwarmStack(ctx, userId, stack, endpoint) } return handler.SwarmStackManager.Remove(ctx, stack, endpoint) diff --git a/api/http/handler/stacks/stack_test_helper.go b/api/http/handler/stacks/stack_test_helper.go index 6653a06fc..4dd331531 100644 --- a/api/http/handler/stacks/stack_test_helper.go +++ b/api/http/handler/stacks/stack_test_helper.go @@ -47,6 +47,7 @@ func mockCreateStackRequestWithSecurityContext(method, target string, body io.Re ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ IsAdmin: true, UserID: portainer.UserID(1), + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, }) return req.WithContext(ctx) diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 5f58062cd..f255a30a8 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/stacks/deployments" @@ -188,7 +189,8 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req deployGate.startDeploy() - if err := fillStackGitConfig(tx, stack); err != nil { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := fillStackGitConfig(tx, userContext, stack); err != nil { return nil, httperror.InternalServerError("Unable to load git config for stack", err) } diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 35b52b38a..eaf74edc8 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/gitops/sources" @@ -87,13 +88,28 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * return httperror.InternalServerError(msg, errors.New(msg)) } - gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - if gitConfig == nil { - msg := "No Git config in the found stack source" - return httperror.InternalServerError(msg, errors.New(msg)) + + var gitConfig *gittypes.RepoConfig + var sourceID portainer.SourceID + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + + if gitConfig == nil { + msg := "No Git config in the found stack source" + return httperror.InternalServerError(msg, errors.New(msg)) + } + + return nil + }); err != nil { + return response.TxErrorResponse(err) } if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" && @@ -126,11 +142,6 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * return httperror.Forbidden("Permission denied to access environment", err) } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) - } - user, err := handler.DataStore.User().Read(securityContext.UserID) if err != nil { return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) @@ -193,8 +204,10 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * stack.Option = &portainer.StackOption{Prune: payload.Prune} } + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if payload.SourceID != 0 { - src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, payload.SourceID) + src, httpErr := sources.ValidateGitSourceAccess(handler.DataStore, userContext, payload.SourceID) if httpErr != nil { return httpErr } @@ -250,11 +263,12 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * if err := tx.Stack().Update(stack.ID, stack); err != nil { return err } - if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, payload.SourceID, gitConfig); err != nil { return err } var err error - resp, err = newStackResponse(tx, stack) + resp, err = newStackResponse(tx, userContext, stack) return err }); err != nil { return httperror.InternalServerError("Unable to persist the stack changes inside the database", err) diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 183e5bfa7..1372f812b 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -8,7 +8,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/workflows" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" @@ -68,27 +70,41 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid stack identifier route variable", err) } - stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) - } else if err != nil { - return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) - } - - if stack.WorkflowID == 0 { - return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow")) - } - - gitConfig, sourceID, err := loadGitConfigForStack(handler.DataStore, stack.WorkflowID, stack.ID) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to load git config for stack", err) - } - if gitConfig == nil { - return httperror.BadRequest("Stack is not created from git", errors.New("stack source has no git config")) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - if stack.Status == portainer.StackStatusDeploying { - return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) + var stack *portainer.Stack + var gitConfig *gittypes.RepoConfig + var sourceID portainer.SourceID + if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + stack, err = tx.Stack().Read(portainer.StackID(stackID)) + if tx.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + if stack.WorkflowID == 0 { + return httperror.BadRequest("Stack is not created from git", errors.New("stack has no git workflow")) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + gitConfig, sourceID, err = loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) + if err != nil { + return httperror.InternalServerError("Unable to load git config for stack", err) + } + if gitConfig == nil { + return httperror.BadRequest("Stack is not created from git", errors.New("stack source has no git config")) + } + + if stack.Status == portainer.StackStatusDeploying { + return httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) + } + return nil + }); err != nil { + return response.TxErrorResponse(err) } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 @@ -113,11 +129,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) return httperror.Forbidden("Permission denied to access environment", err) } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) - } - // Only check resource control when it is a DockerSwarmStack or a DockerComposeStack if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) @@ -254,11 +265,12 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) if err := tx.Stack().Update(stack.ID, stack); err != nil { return err } - if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) + if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { return err } - return fillStackGitConfig(tx, stack) + return fillStackGitConfig(tx, userContext, stack) }); err != nil { deployGate.abortDeploy() diff --git a/api/http/handler/stacks/stack_update_git_test.go b/api/http/handler/stacks/stack_update_git_test.go index 576160173..ea9cab4db 100644 --- a/api/http/handler/stacks/stack_update_git_test.go +++ b/api/http/handler/stacks/stack_update_git_test.go @@ -8,6 +8,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/datastore" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" @@ -36,30 +37,23 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) { const stack1ID = portainer.StackID(456) const stack2ID = portainer.StackID(457) - src1 := &portainer.Source{ + sharedSrc := &portainer.Source{ Type: portainer.SourceTypeGit, Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"}, } - err = store.Source().Create(src1) + err = store.Source().Create(source.InsecureNewAdminContext(), sharedSrc) require.NoError(t, err) wf1 := &portainer.Workflow{Artifacts: []portainer.Artifact{{ StackID: stack1ID, - Files: []portainer.ArtifactFile{{SourceID: src1.ID}}, + Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}}, }}} err = store.Workflow().Create(wf1) require.NoError(t, err) - src2 := &portainer.Source{ - Type: portainer.SourceTypeGit, - Git: &gittypes.RepoConfig{URL: "https://github.com/portainer/portainer.git"}, - } - err = store.Source().Create(src2) - require.NoError(t, err) - wf2 := &portainer.Workflow{Artifacts: []portainer.Artifact{{ StackID: stack2ID, - Files: []portainer.ArtifactFile{{SourceID: src2.ID}}, + Files: []portainer.ArtifactFile{{SourceID: sharedSrc.ID}}, }}} err = store.Workflow().Create(wf2) require.NoError(t, err) @@ -99,7 +93,11 @@ func TestStackUpdateGitWebhookUniqueness(t *testing.T) { url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID)) req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload)) - rrc := &security.RestrictedRequestContext{} + rrc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + User: &portainer.User{ID: 1, Role: portainer.AdministratorRole}, + } req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc)) rr := httptest.NewRecorder() diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index 0a543b974..6270eaf6d 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -8,6 +8,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/git/update" @@ -54,8 +55,15 @@ func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error } func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships) if stack.WorkflowID != 0 { - gitConfig, sourceID, err := loadGitConfigForStack(tx, stack.WorkflowID, stack.ID) + gitConfig, sourceID, err := loadGitConfigForStack(tx, userContext, stack.WorkflowID, stack.ID) if err != nil { return httperror.InternalServerError("Unable to load git config for stack", err) } @@ -111,7 +119,7 @@ func (handler *Handler) updateKubernetesStack(tx dataservices.DataStoreTx, r *ht stack.AutoUpdate.JobID = jobID } - if err := saveStackGitConfig(tx, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { + if err := saveStackGitConfig(tx, userContext, stack.WorkflowID, stack.ID, sourceID, 0, gitConfig); err != nil { return httperror.InternalServerError("Unable to update source git config", err) } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index a4a7d9a7c..d71ce34f0 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -58,6 +58,7 @@ type ( IsTeamLeader bool UserID portainer.UserID UserMemberships []portainer.TeamMembership + User *portainer.User } // tokenLookup looks up a token in the request @@ -274,7 +275,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h return } - requestContext, err := bouncer.newRestrictedContextRequest(tokenData.ID, tokenData.Role) + requestContext, err := newRestrictedContextRequest(bouncer.dataStore, tokenData.ID, tokenData.Role) if err != nil { httperror.WriteError(w, http.StatusInternalServerError, "Unable to create restricted request context ", err) 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 { return &RestrictedRequestContext{ IsAdmin: true, UserID: userID, + User: user, }, nil } - memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(userID) + memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID) if err != nil { return nil, err } @@ -557,6 +564,7 @@ func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.User UserID: userID, IsTeamLeader: isTeamLeader, UserMemberships: memberships, + User: user, }, nil } diff --git a/api/internal/testhelpers/stack_deployer.go b/api/internal/testhelpers/stack_deployer.go index 4024545ee..9d39223be 100644 --- a/api/internal/testhelpers/stack_deployer.go +++ b/api/internal/testhelpers/stack_deployer.go @@ -36,34 +36,34 @@ func (d *TestStackDeployer) DeployKubernetesStack(_ context.Context, stack *port 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 } -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 } -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 } -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 } -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 } -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 } -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 } -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 } diff --git a/api/portainer.go b/api/portainer.go index 1bca4c04e..a66601466 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1325,6 +1325,12 @@ type ( Git *gittypes.RepoConfig `json:"git,omitempty"` Registry *Registry `json:"registry,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 diff --git a/api/stacks/deployments/deploy.go b/api/stacks/deployments/deploy.go index 0f8b91d84..936a56a20 100644 --- a/api/stacks/deployments/deploy.go +++ b/api/stacks/deployments/deploy.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/portainer/api/agent" "github.com/portainer/portainer/api/crypto" "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/gitops/workflows" "github.com/portainer/portainer/api/http/security" @@ -121,7 +122,17 @@ func redeployWhenChangedSecondStage( user *portainer.User, endpoint *portainer.Endpoint, ) 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 { return errors.WithMessagef(err, "failed to load git config for stack %v", stack.ID) } @@ -184,7 +195,7 @@ func redeployWhenChangedSecondStage( switch stack.Type { case portainer.DockerComposeStack: 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 { err = deployer.DeployComposeStack(ctx, stack, endpoint, registries, true, true, false) } @@ -194,7 +205,7 @@ func redeployWhenChangedSecondStage( } case portainer.DockerSwarmStack: 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 { err = deployer.DeploySwarmStack(ctx, stack, endpoint, registries, true, true) } diff --git a/api/stacks/deployments/deploy_test.go b/api/stacks/deployments/deploy_test.go index e0fda0080..9d47e2dbb 100644 --- a/api/stacks/deployments/deploy_test.go +++ b/api/stacks/deployments/deploy_test.go @@ -13,6 +13,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/datastore" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/internal/testhelpers" @@ -77,6 +78,8 @@ L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg== -----END RSA PRIVATE KEY-----` +var adminUserContext = source.InsecureNewAdminContext() + type noopDeployer struct{} // without unpacker @@ -97,28 +100,28 @@ func (s noopDeployer) DeployKubernetesStack(_ context.Context, stack *portainer. } // 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 } -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 } -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 } -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 } -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 } -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 } -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 } -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 } @@ -191,7 +194,7 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) { 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) require.NoError(t, err, "error creating an admin") @@ -206,7 +209,7 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) { ConfigHash: "oldHash", }, } - err = store.Source().Create(src) + err = store.Source().Create(adminUserContext, src) require.NoError(t, err, "failed to create source") 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") _, 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) require.NoError(t, err, "error creating an admin") @@ -253,7 +256,7 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) { ConfigHash: "oldHash", }, } - err = store.Source().Create(src) + err = store.Source().Create(adminUserContext, src) require.NoError(t, err, "failed to create source") wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{ @@ -296,7 +299,7 @@ func setupRedeployStore(t *testing.T, stackType portainer.StackType) (dataservic ConfigHash: "oldHash", }, } - err = store.Source().Create(src) + err = store.Source().Create(adminUserContext, src) require.NoError(t, err, "failed to create source") 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) 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{ ID: 1, diff --git a/api/stacks/deployments/deployer_remote.go b/api/stacks/deployments/deployer_remote.go index 033932467..fa57de53e 100644 --- a/api/stacks/deployments/deployer_remote.go +++ b/api/stacks/deployments/deployer_remote.go @@ -10,6 +10,8 @@ import ( "time" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/logs" @@ -35,20 +37,21 @@ const ( type RemoteStackDeployer interface { // compose - DeployRemoteComposeStack(ctx context.Context, 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 - StartRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error - StopRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) 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, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error + StartRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error + StopRemoteComposeStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error // swarm - DeployRemoteSwarmStack(ctx context.Context, 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 - StartRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error - StopRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) 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, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint) error + StartRemoteSwarmStack(ctx context.Context, userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) 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 func (d *stackDeployer) DeployRemoteComposeStack( ctx context.Context, + userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, @@ -70,6 +73,7 @@ func (d *stackDeployer) DeployRemoteComposeStack( return d.remoteStack( ctx, + userId, stack, endpoint, 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 -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() 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 func (d *stackDeployer) StartRemoteComposeStack( ctx context.Context, + userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, ) error { return d.remoteStack( ctx, + userId, stack, endpoint, 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 -func (d *stackDeployer) StopRemoteComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { - return d.remoteStack(ctx, stack, endpoint, OperationComposeStop, unpackerCmdBuilderOptions{}) +func (d *stackDeployer) StopRemoteComposeStack( + 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 func (d *stackDeployer) DeployRemoteSwarmStack( ctx context.Context, + userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, @@ -124,7 +141,7 @@ func (d *stackDeployer) DeployRemoteSwarmStack( d.lock.Lock() defer d.lock.Unlock() - return d.remoteStack(ctx, stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{ + return d.remoteStack(ctx, userId, stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{ pullImage: pullImage, prune: prune, 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 -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() 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 func (d *stackDeployer) StartRemoteSwarmStack( ctx context.Context, + userId portainer.UserID, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, ) error { return d.remoteStack( ctx, + userId, stack, endpoint, 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 -func (d *stackDeployer) StopRemoteSwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { - return d.remoteStack(ctx, stack, endpoint, OperationSwarmStop, unpackerCmdBuilderOptions{}) +func (d *stackDeployer) StopRemoteSwarmStack( + 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: @@ -167,15 +203,31 @@ func (d *stackDeployer) StopRemoteSwarmStack(ctx context.Context, stack *portain // * deploy compose-unpacker container // * wait for deployment to end // * 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 { - src, file, err := workflows.GitSourceAndArtifactForStack(d.dataStore, stack.WorkflowID, stack.ID) - if err != nil { - return errors.Wrap(err, "failed to load git config for remote stack") - } + if err := d.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + user, err := tx.User().Read(userID) + if err != nil { + return err + } - if src != nil { - opts.gitConfig = workflows.MergeSourceAndFile(src, file) + 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 { + return errors.Wrap(err, "failed to load git config for remote stack") + } + + if src != nil { + opts.gitConfig = workflows.MergeSourceAndFile(src, file) + } + return nil + }); err != nil { + return err } } diff --git a/api/stacks/deployments/deployment_compose_config.go b/api/stacks/deployments/deployment_compose_config.go index a34865c60..bd214fb07 100644 --- a/api/stacks/deployments/deployment_compose_config.go +++ b/api/stacks/deployments/deployment_compose_config.go @@ -76,7 +76,7 @@ func (config *ComposeStackDeploymentConfig) Deploy(ctx context.Context) error { } 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) @@ -89,7 +89,7 @@ func (config *ComposeStackDeploymentConfig) Undeploy(ctx context.Context) error } 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) } diff --git a/api/stacks/deployments/deployment_swarm_config.go b/api/stacks/deployments/deployment_swarm_config.go index 51d9a3f09..31224202b 100644 --- a/api/stacks/deployments/deployment_swarm_config.go +++ b/api/stacks/deployments/deployment_swarm_config.go @@ -76,7 +76,7 @@ func (config *SwarmStackDeploymentConfig) Deploy(ctx context.Context) error { } 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) diff --git a/api/stacks/stackbuilders/stack_git_builder.go b/api/stacks/stackbuilders/stack_git_builder.go index f5c9721cd..15d3aa917 100644 --- a/api/stacks/stackbuilders/stack_git_builder.go +++ b/api/stacks/stackbuilders/stack_git_builder.go @@ -7,12 +7,14 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/workflows" "github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/ssrf" ) @@ -30,11 +32,28 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo 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 sourceID portainer.SourceID if payload.SourceID != 0 { - src, err := b.dataStore.Source().Read(payload.SourceID) + src, err := b.dataStore.Source().Read(userContext, payload.SourceID) if err != nil { return fmt.Errorf("failed to read source: %w", err) } @@ -105,7 +124,7 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo } else { 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), Type: portainer.SourceTypeGit, Git: &repoConfig, diff --git a/api/stacks/stackbuilders/stack_git_builder_test.go b/api/stacks/stackbuilders/stack_git_builder_test.go index 240f8eead..fab9b700c 100644 --- a/api/stacks/stackbuilders/stack_git_builder_test.go +++ b/api/stacks/stackbuilders/stack_git_builder_test.go @@ -5,6 +5,7 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices/source" "github.com/portainer/portainer/api/datastore" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/gitops/workflows" @@ -13,6 +14,8 @@ import ( "github.com/stretchr/testify/require" ) +var adminUserContext = source.InsecureNewAdminContext() + // stubFileService satisfies portainer.FileService for git builder tests. type stubFileService struct { portainer.FileService @@ -25,7 +28,7 @@ func (s *stubFileService) GetStackProjectPath(stackIdentifier string) string { func newGitMethodBuilder(t *testing.T, commitHash string) *GitMethodStackBuilder { t.Helper() _, 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{ StackBuilder: StackBuilder{ 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{ RepositoryConfigPayload: RepositoryConfigPayload{ @@ -69,12 +72,12 @@ func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing. assert.Equal(t, src.ID, referencedSourceID) // 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) assert.Len(t, allSources, 1) // 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) merged := workflows.MergeSourceAndFile(readSrc, artifact) 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) // 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) assert.Len(t, allSources, 1) assert.Equal(t, "https://github.com/org/public-repo", allSources[0].Git.URL) diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 21a89b263..dc21a6d60 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -329,9 +329,6 @@ angular component: 'sourcesListView', }, }, - data: { - access: AccessHeaders.Admin, - }, }; var gitopsSourceDetail = { diff --git a/app/portainer/react/components/access-control.ts b/app/portainer/react/components/access-control.ts index 71e4c1835..ce36739e7 100644 --- a/app/portainer/react/components/access-control.ts +++ b/app/portainer/react/components/access-control.ts @@ -21,6 +21,7 @@ export const accessControlModule = angular 'resourceId', 'resourceType', 'environmentId', + 'resourceName', ]) ) .component( @@ -32,6 +33,7 @@ export const accessControlModule = angular 'onChange', 'value', 'teams', + 'resourceName', ]) ) .component( diff --git a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx index c47385690..02d8cfe77 100644 --- a/app/react/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx +++ b/app/react/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx @@ -26,6 +26,7 @@ interface Props { environmentId: EnvironmentId; disableOwnershipChange?: boolean; onUpdateSuccess(): Promise; + resourceName?: string; } export function AccessControlPanel({ @@ -35,6 +36,7 @@ export function AccessControlPanel({ resourceId, environmentId, onUpdateSuccess, + resourceName = 'resource', }: Props) { const [isEditMode, toggleEditMode] = useReducer((state) => !state, false); const isAdminQuery = useIsEdgeAdmin(); @@ -62,6 +64,7 @@ export function AccessControlPanel({ {ownership} - + {inheritanceMessage} @@ -102,22 +104,25 @@ export function AccessControlPanelDetails({ ); } -function getOwnershipTooltip(ownership: ResourceControlOwnership) { +function getOwnershipTooltip( + ownership: ResourceControlOwnership, + resourceName: string +) { switch (ownership) { 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: - 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: - 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: default: - return 'This resource can only be managed by administrators.'; + return `This ${resourceName} can only be managed by administrators.`; } } function getInheritanceMessage( - resourceType: ResourceControlType, + resourceType: ResourceControlType | undefined, resourceControl?: ResourceControlViewModel ) { if (!resourceControl || resourceControl.Type === resourceType) { diff --git a/app/react/portainer/access-control/EditDetails/AccessTypeSelector.tsx b/app/react/portainer/access-control/EditDetails/AccessTypeSelector.tsx index 5214b35f6..c62579fa9 100644 --- a/app/react/portainer/access-control/EditDetails/AccessTypeSelector.tsx +++ b/app/react/portainer/access-control/EditDetails/AccessTypeSelector.tsx @@ -12,6 +12,7 @@ export function AccessTypeSelector({ teams, value, onChange, + resourceName = 'resource', }: { name: string; isAdmin: boolean; @@ -19,8 +20,9 @@ export function AccessTypeSelector({ isPublicVisible: boolean; value: ResourceControlOwnership; onChange(value: ResourceControlOwnership): void; + resourceName?: string; }) { - const options = useOptions(isAdmin, teams, isPublicVisible); + const options = useOptions(isAdmin, teams, isPublicVisible, resourceName); return ( ; formNamespace?: string; - environmentId: EnvironmentId; + resourceName?: string; + environmentId?: EnvironmentId; } export function EditDetails({ @@ -28,6 +29,7 @@ export function EditDetails({ isPublicVisible = false, errors, formNamespace, + resourceName = 'resource', environmentId, }: Props) { const { user, isPureAdmin } = useCurrentUser(); @@ -60,6 +62,7 @@ export function EditDetails({ isAdmin={isPureAdmin} isPublicVisible={isPublicVisible} teams={teams} + resourceName={resourceName} /> {values.ownership === ResourceControlOwnership.RESTRICTED && ( diff --git a/app/react/portainer/access-control/EditDetails/useLoadState.ts b/app/react/portainer/access-control/EditDetails/useLoadState.ts index c0b0dcc7d..50b1c9278 100644 --- a/app/react/portainer/access-control/EditDetails/useLoadState.ts +++ b/app/react/portainer/access-control/EditDetails/useLoadState.ts @@ -3,7 +3,7 @@ import { useUsers } from '@/portainer/users/queries'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { useIsEdgeAdmin } from '@/react/hooks/useUser'; -export function useLoadState(environmentId: EnvironmentId) { +export function useLoadState(environmentId?: EnvironmentId) { const isAdminQuery = useIsEdgeAdmin(); const teams = useTeams(false, environmentId); diff --git a/app/react/portainer/access-control/EditDetails/useOptions.tsx b/app/react/portainer/access-control/EditDetails/useOptions.tsx index af2194798..5f5ded79a 100644 --- a/app/react/portainer/access-control/EditDetails/useOptions.tsx +++ b/app/react/portainer/access-control/EditDetails/useOptions.tsx @@ -10,34 +10,42 @@ import { BadgeIcon } from '@@/BadgeIcon'; import { ResourceControlOwnership } from '../types'; -const publicOption: BoxSelectorOption = { - value: ResourceControlOwnership.PUBLIC, - label: 'Public', - id: 'access_public', - description: - 'I want any user with access to this environment to be able to manage this resource', - icon: , -}; +function publicOption( + resourceName: string +): BoxSelectorOption { + return { + value: ResourceControlOwnership.PUBLIC, + label: 'Public', + id: 'access_public', + description: `I want any user with access to this ${resourceName} to be able to manage this ${resourceName}`, + icon: , + }; +} export function useOptions( isAdmin: boolean, teams?: Team[], - isPublicVisible = false + isPublicVisible = false, + resourceName = 'resource' ) { const [options, setOptions] = useState< Array> >([]); useEffect(() => { - const options = isAdmin ? adminOptions() : nonAdminOptions(teams); + const options = isAdmin + ? adminOptions(resourceName) + : nonAdminOptions(teams, resourceName); - setOptions(isPublicVisible ? [...options, publicOption] : options); - }, [isAdmin, teams, isPublicVisible]); + setOptions( + isPublicVisible ? [...options, publicOption(resourceName)] : options + ); + }, [isAdmin, teams, isPublicVisible, resourceName]); return options; } -function adminOptions() { +function adminOptions(resourceName: string) { return [ buildOption( 'access_administrators', @@ -45,25 +53,25 @@ function adminOptions() { icon={ownershipIcon(ResourceControlOwnership.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 ), buildOption( 'access_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 ), ]; } -function nonAdminOptions(teams?: Team[]) { +function nonAdminOptions(teams?: Team[], resourceName = 'resource') { return _.compact([ buildOption( 'access_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 ), teams && @@ -75,12 +83,12 @@ function nonAdminOptions(teams?: Team[]) { teams.length === 1 ? ( <> I want any member of my team ({teams[0].Name}) to be able to - manage this resource + manage this {resourceName} ) : ( <> - I want to restrict the management of this resource to one or more of - my teams + I want to restrict the management of this {resourceName} to one or + more of my teams ), ResourceControlOwnership.RESTRICTED diff --git a/app/react/portainer/access-control/types.ts b/app/react/portainer/access-control/types.ts index 6679ceb52..0fd6826a8 100644 --- a/app/react/portainer/access-control/types.ts +++ b/app/react/portainer/access-control/types.ts @@ -44,7 +44,7 @@ export enum ResourceControlType { ContainerGroup, } -enum ResourceAccessLevel { +export enum ResourceAccessLevel { ReadWriteAccessLevel = 1, } diff --git a/app/react/portainer/generated-api/portainer/sdk.gen.ts b/app/react/portainer/generated-api/portainer/sdk.gen.ts index 48b84a142..0ff091df1 100644 --- a/app/react/portainer/generated-api/portainer/sdk.gen.ts +++ b/app/react/portainer/generated-api/portainer/sdk.gen.ts @@ -455,6 +455,9 @@ import type { GitOpsSourcesTestData, GitOpsSourcesTestErrors, GitOpsSourcesTestResponses, + GitOpsSourcesUpdateAccessData, + GitOpsSourcesUpdateAccessErrors, + GitOpsSourcesUpdateAccessResponses, GitOpsSourcesUpdateGitData, GitOpsSourcesUpdateGitErrors, GitOpsSourcesUpdateGitResponses, @@ -2557,7 +2560,7 @@ export const gitOpsSourcesList = ( * Delete a source * * 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 = ( options: Options @@ -2602,7 +2605,7 @@ export const gitOpsSourceGet = ( * Update a Git source * * Updates an existing GitOps source backed by a Git repository. - * **Access policy**: administrator + * **Access policy**: authenticated */ export const gitOpsSourcesUpdateGit = ( options: Options @@ -2625,11 +2628,38 @@ export const gitOpsSourcesUpdateGit = ( }, }); +/** + * Update a GitOps source's access control + * + * Updates the access control settings for an existing GitOps source. + * **Access policy**: admin + */ +export const gitOpsSourcesUpdateAccess = ( + options: Options +) => + (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 * * Tests connectivity for a GitOps source, applying optional overrides to the stored configuration. - * **Access policy**: administrator + * **Access policy**: authenticated */ export const gitOpsSourcesTestById = ( options: Options @@ -2656,7 +2686,7 @@ export const gitOpsSourcesTestById = ( * Create a Git source * * Creates a new GitOps source backed by a Git repository. - * **Access policy**: administrator + * **Access policy**: authenticated */ export const gitOpsSourcesCreateGit = ( options: Options @@ -2706,7 +2736,7 @@ export const gitOpsSourcesSummary = ( * Test a Git source connection * * Tests connectivity for Git connection details that have not been persisted yet. - * **Access policy**: administrator + * **Access policy**: authenticated */ export const gitOpsSourcesTest = ( options: Options diff --git a/app/react/portainer/generated-api/portainer/types.gen.ts b/app/react/portainer/generated-api/portainer/types.gen.ts index c2e137a62..5c2f975d6 100644 --- a/app/react/portainer/generated-api/portainer/types.gen.ts +++ b/app/react/portainer/generated-api/portainer/types.gen.ts @@ -4603,6 +4603,7 @@ export type SourcesSourceType = (typeof SourcesSourceType)[keyof typeof SourcesSourceType]; export type SourcesSourceDetail = { + access?: SourcesSourceAccess; autoUpdate?: SourcesAutoUpdateInfo; connection: SourcesConnectionInfo; environments?: number; @@ -4622,6 +4623,18 @@ export type SourcesAutoUpdateInfo = { mechanism?: string; }; +export type SourcesSourceAccess = { + public?: boolean; + teams?: Array; + users?: Array; +}; + +export type SourcesSourceAccessUpdatePayload = { + public?: boolean; + teams?: Array; + users?: Array; +}; + export type SourcesSource = { environments?: number; error?: string; @@ -4648,10 +4661,14 @@ export type SourcesGitAuthenticationUpdatePayload = { }; export type SourcesGitSourceCreatePayload = { + administratorsOnly?: boolean; authentication?: SourcesGitAuthenticationPayload; name?: string; + public?: boolean; + teamAccesses?: Array; tlsSkipVerify?: boolean; url: string; + userAccesses?: Array; }; export type SourcesGitAuthenticationPayload = { @@ -5860,13 +5877,18 @@ export type PortainerSourceType = (typeof PortainerSourceType)[keyof typeof PortainerSourceType]; export type PortainerSource = { + administratorsOnly?: boolean; git?: GittypesRepoConfig; helm?: PortainerHelmConfig; id?: number; lastSync?: number; name?: string; + ownerID?: number; + public?: boolean; registry?: PortainerRegistry; + teamAccesses?: Array; type?: PortainerSourceType; + userAccesses?: Array; }; export type PortainerRegistryManagementConfiguration = { @@ -11484,6 +11506,50 @@ export type GitOpsSourcesUpdateGitResponses = { export type GitOpsSourcesUpdateGitResponse = 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 = { /** * Optional connection overrides; omitted fields fall back to stored values diff --git a/app/react/portainer/generated-api/portainer/zod.gen.ts b/app/react/portainer/generated-api/portainer/zod.gen.ts index 6036ec483..5882057be 100644 --- a/app/react/portainer/generated-api/portainer/zod.gen.ts +++ b/app/react/portainer/generated-api/portainer/zod.gen.ts @@ -1223,7 +1223,14 @@ export const zSourcesAutoUpdateInfo = z.object({ 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({ + access: zSourcesSourceAccess.optional(), autoUpdate: zSourcesAutoUpdateInfo.optional(), connection: zSourcesConnectionInfo, environments: z.int().optional(), @@ -1238,6 +1245,12 @@ export const zSourcesSourceDetail = z.object({ 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({ environments: z.int().optional(), error: z.string().optional(), @@ -1269,10 +1282,14 @@ export const zSourcesGitAuthenticationPayload = z.object({ }); export const zSourcesGitSourceCreatePayload = z.object({ + administratorsOnly: z.boolean().optional(), authentication: zSourcesGitAuthenticationPayload.optional(), name: z.string().optional(), + public: z.boolean().optional(), + teamAccesses: z.array(z.int()).optional(), tlsSkipVerify: z.boolean().optional(), url: z.string(), + userAccesses: z.array(z.int()).optional(), }); export const zSourcesConnectionTestResult = z.object({ @@ -1817,13 +1834,18 @@ export const zPortainerHelmConfig = z.object({ }); export const zPortainerSource = z.object({ + administratorsOnly: z.boolean().optional(), git: zGittypesRepoConfig.optional(), helm: zPortainerHelmConfig.optional(), id: z.int().optional(), lastSync: z.int().optional(), name: z.string().optional(), + ownerID: z.int().optional(), + public: z.boolean().optional(), registry: zPortainerRegistry.optional(), + teamAccesses: z.array(z.int()).optional(), type: zPortainerSourceType.optional(), + userAccesses: z.array(z.int()).optional(), }); export const zPortainerEdge = z.object({ @@ -4087,6 +4109,20 @@ export const zGitOpsSourcesUpdateGitPath = z.object({ */ 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 */ diff --git a/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx b/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx index b5b527bfe..940e998e1 100644 --- a/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx +++ b/app/react/portainer/gitops/sources/CreateView/CreateForm.tsx @@ -2,6 +2,8 @@ import { Form, Formik, FormikHelpers } from 'formik'; import { useRouter } from '@uirouter/react'; import { notifySuccess } from '@/portainer/services/notifications'; +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; +import { useIsPureAdmin } from '@/react/hooks/useUser'; import { Widget } from '@@/Widget'; @@ -11,27 +13,33 @@ import { WizardStep, useWizardContext } from './WizardContext'; import { WizardHeader } from './WizardHeader'; import { WizardFooter } from './WizardFooter'; -const initialFormValues: FormValues = { - name: '', - type: 'git', - git: { - url: '', - authentication: { - authEnabled: true, - }, - connectionOk: false, - }, -}; - 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: '', + type: 'git', + git: { + url: '', + authentication: { + authEnabled: true, + }, + connectionOk: false, + }, + authorizedTeams: [], + authorizedUsers: [], + ownership: isAdmin + ? ResourceControlOwnership.ADMINISTRATORS + : ResourceControlOwnership.PRIVATE, + }; + return ( (); + return ( <> - + + { + setValues((values) => ({ + ...values, + ownership, + authorizedTeams, + authorizedUsers, + })); + }} + errors={errors} + isPublicVisible + /> + ); } + +export function validateAccessControlStep() { + return validationSchema().pick([ + 'ownership', + 'authorizedUsers', + 'authorizedTeams', + ]); +} diff --git a/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx b/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx index 2b6464c92..6ae5d9bf8 100644 --- a/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx +++ b/app/react/portainer/gitops/sources/CreateView/steps/ConnectionTest.test.tsx @@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw'; import { server } from '@/setup-tests/server'; import { suppressConsoleLogs } from '@/setup-tests/suppress-console'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; import { FormValues } from '../type'; @@ -33,6 +34,9 @@ function renderConnectionTest(gitValues: FormValues['git']) { name: 'test-source', type: 'git', git: gitValues, + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, }; const Wrapped = withTestQueryProvider(ConnectionTest); diff --git a/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx b/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx index 73d44b999..462223bc2 100644 --- a/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx +++ b/app/react/portainer/gitops/sources/CreateView/steps/TypeSelectStep.tsx @@ -15,7 +15,7 @@ export function TypeSelectStep() { <> { it('populates authentication when authEnabled with username and password', () => { const payload = formValuesToCreatePayload({ + ...baseUAC, name: 'my-source', type: 'git', git: { @@ -29,6 +41,7 @@ describe('formValuesToCreatePayload', () => { it('omits authentication when authEnabled is false', () => { const payload = formValuesToCreatePayload({ + ...baseUAC, name: 'my-source', type: 'git', git: { @@ -42,6 +55,7 @@ describe('formValuesToCreatePayload', () => { it('omits authentication when authEnabled but username is missing', () => { const payload = formValuesToCreatePayload({ + ...baseUAC, name: 'my-source', type: 'git', git: { @@ -58,6 +72,7 @@ describe('formValuesToCreatePayload', () => { it('omits authentication when authEnabled but password is missing', () => { const payload = formValuesToCreatePayload({ + ...baseUAC, name: 'my-source', type: 'git', git: { @@ -74,6 +89,7 @@ describe('formValuesToCreatePayload', () => { it('does not include connectionOk in the create payload', () => { const payload = formValuesToCreatePayload({ + ...baseUAC, name: 'my-source', type: 'git', git: { diff --git a/app/react/portainer/gitops/sources/CreateView/type.ts b/app/react/portainer/gitops/sources/CreateView/type.ts index 44d14b7b6..dc12cb67e 100644 --- a/app/react/portainer/gitops/sources/CreateView/type.ts +++ b/app/react/portainer/gitops/sources/CreateView/type.ts @@ -3,6 +3,11 @@ import { type SourcesGitSourceCreatePayload, } from '@api/types.gen'; +import { + AccessControlFormData, + ResourceControlOwnership, +} from '@/react/portainer/access-control/types'; + import { CreateSourcePayload } from './useSourceCreateMutation'; type GitFormValues = { @@ -19,7 +24,7 @@ type GitFormValues = { export const FormValueTypes = ['git', 'registry', 'helm'] as const; -export type FormValues = { +export type FormValues = AccessControlFormData & { name: string; type: (typeof FormValueTypes)[number]; git: GitFormValues; @@ -29,6 +34,9 @@ export function formValuesToCreatePayload({ name, type, git: { authentication, tlsSkipVerify, url }, + authorizedTeams, + authorizedUsers, + ownership, }: FormValues): CreateSourcePayload { return { type, @@ -37,6 +45,10 @@ export function formValuesToCreatePayload({ tlsSkipVerify, url, authentication: buildAuthPayload(authentication), + administratorsOnly: ownership === ResourceControlOwnership.ADMINISTRATORS, + public: ownership === ResourceControlOwnership.PUBLIC, + teamAccesses: authorizedTeams, + userAccesses: authorizedUsers, }, }; } diff --git a/app/react/portainer/gitops/sources/CreateView/validation.test.ts b/app/react/portainer/gitops/sources/CreateView/validation.test.ts index 27b5bf917..eb86b7bac 100644 --- a/app/react/portainer/gitops/sources/CreateView/validation.test.ts +++ b/app/react/portainer/gitops/sources/CreateView/validation.test.ts @@ -1,3 +1,6 @@ +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; + +import { FormValues } from './type'; import { validateGitConnection, validationSchema } from './validation'; const baseAuth = { @@ -74,7 +77,10 @@ describe('validationSchema git.authentication', () => { ...validGitValues, authentication: { ...baseAuth, authEnabled: true }, }, - }); + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, + } satisfies FormValues); expect(result).toBe(false); }); @@ -84,7 +90,10 @@ describe('validationSchema git.authentication', () => { name: 'src', type: 'git', git: validGitValues, - }); + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, + } satisfies FormValues); expect(result).toBe(true); }); @@ -102,7 +111,10 @@ describe('validationSchema git.authentication', () => { password: 'secret', }, }, - }); + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, + } satisfies FormValues); expect(result).toBe(true); }); }); @@ -117,7 +129,10 @@ describe('validationSchema full git (requires connectionOk)', () => { ...validGitValues, connectionOk: false, }, - }); + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, + } satisfies FormValues); expect(result).toBe(false); }); @@ -127,7 +142,10 @@ describe('validationSchema full git (requires connectionOk)', () => { name: 'src', type: 'git', git: validGitValues, - }); + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, + } satisfies FormValues); expect(result).toBe(true); }); @@ -137,7 +155,10 @@ describe('validationSchema full git (requires connectionOk)', () => { name: '', type: 'git', git: validGitValues, - }); + authorizedTeams: [], + authorizedUsers: [], + ownership: ResourceControlOwnership.ADMINISTRATORS, + } satisfies FormValues); expect(result).toBe(false); }); }); diff --git a/app/react/portainer/gitops/sources/CreateView/validation.tsx b/app/react/portainer/gitops/sources/CreateView/validation.tsx index 1515dd919..9c300d167 100644 --- a/app/react/portainer/gitops/sources/CreateView/validation.tsx +++ b/app/react/portainer/gitops/sources/CreateView/validation.tsx @@ -1,4 +1,7 @@ -import { bool, mixed, object, SchemaOf, string } from 'yup'; +import { array, bool, mixed, number, object, SchemaOf, string } from 'yup'; + +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; +import { stringEnumValues } from '@/types'; import { isValidUrl } from '@@/form-components/validate-url'; @@ -12,6 +15,11 @@ export function validationSchema(): SchemaOf { .required() .default('git'), git: validateGit(), + ownership: mixed() + .oneOf(stringEnumValues(ResourceControlOwnership)) + .required(), + authorizedTeams: array().of(number().required()), + authorizedUsers: array().of(number().required()), }); } diff --git a/app/react/portainer/gitops/sources/ItemView/AccessTab.tsx b/app/react/portainer/gitops/sources/ItemView/AccessTab.tsx new file mode 100644 index 000000000..3147b5f64 --- /dev/null +++ b/app/react/portainer/gitops/sources/ItemView/AccessTab.tsx @@ -0,0 +1,187 @@ +import { useRef } from 'react'; +import { Form, Formik, FormikProps } from 'formik'; +import { Eye } from 'lucide-react'; + +import { notifySuccess } from '@/portainer/services/notifications'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { + AccessControlFormData, + ResourceAccessLevel, + ResourceControlOwnership, + ResourceControlType, +} from '@/react/portainer/access-control/types'; +import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; +import { validationSchema } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation'; +import { EditDetails } from '@/react/portainer/access-control/EditDetails'; +import { AccessControlPanelDetails } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanelDetails'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; + +import { Widget } from '@@/Widget'; +import { Button } from '@@/buttons'; +import { LoadingButton } from '@@/buttons/LoadingButton'; +import { StickyFooter } from '@@/StickyFooter/StickyFooter'; +import { usePreventFormExit } from '@@/form-components/usePreventFormExit'; + +import { SourceDetail } from '../queries/useSource'; +import { + UpdateSourceAccessPayload, + useUpdateSourceAccessMutation, +} from '../queries/useUpdateSourceAccessMutation'; + +interface Props { + source: SourceDetail; + isEditing: boolean; + onEditingChange: (isEditing: boolean) => void; +} + +export function AccessTab({ source, isEditing, onEditingChange }: Props) { + if (isEditing) { + return ( + onEditingChange(false)} /> + ); + } + + return ( + + + + + + + ); +} + +function AccessForm({ + source, + onClose, +}: { + source: SourceDetail; + onClose: () => void; +}) { + const { user, isPureAdmin } = useCurrentUser(); + const updateAccess = useUpdateSourceAccessMutation(source.id); + const formikRef = useRef>(null); + + usePreventFormExit(() => !!formikRef.current?.dirty); + + const initialValues = parseAccessControlFormData( + isPureAdmin, + user.Id, + toResourceControl(source) + ); + + return ( + validationSchema(isPureAdmin)} + onSubmit={(values, { setSubmitting }) => + updateAccess.mutate(toAccessPayload(values), { + onSuccess: () => { + notifySuccess('Source access updated', ''); + onClose(); + }, + onSettled: () => setSubmitting(false), + }) + } + > + {({ + handleSubmit, + values, + errors, + isValid, + dirty, + isSubmitting, + setValues, + }) => ( + +
+ + + + + + + + + + + Save Changes + + +
+
+ )} +
+ ); +} + +function toResourceControl(source: SourceDetail): ResourceControlViewModel { + const { + public: isPublic = false, + users = [], + teams = [], + } = source.access ?? {}; + + return new ResourceControlViewModel({ + Id: 0, + // Sources aren't an inheritable resource, so Type is irrelevant here; it's + // only required by the model and never read for sources. + Type: ResourceControlType.CustomTemplate, + ResourceId: source.id, + Public: isPublic, + AdministratorsOnly: !isPublic && users.length === 0 && teams.length === 0, + System: false, + UserAccesses: users.map((UserId) => ({ + UserId, + AccessLevel: ResourceAccessLevel.ReadWriteAccessLevel, + })), + TeamAccesses: teams.map((TeamId) => ({ + TeamId, + AccessLevel: ResourceAccessLevel.ReadWriteAccessLevel, + })), + }); +} + +function toAccessPayload({ + ownership, + authorizedUsers, + authorizedTeams, +}: AccessControlFormData): UpdateSourceAccessPayload { + const isRestricted = + ownership === ResourceControlOwnership.RESTRICTED || + ownership === ResourceControlOwnership.PRIVATE; + + return { + public: ownership === ResourceControlOwnership.PUBLIC, + users: isRestricted ? authorizedUsers : [], + teams: isRestricted ? authorizedTeams : [], + }; +} diff --git a/app/react/portainer/gitops/sources/ItemView/ItemView.tsx b/app/react/portainer/gitops/sources/ItemView/ItemView.tsx index 3305084d0..03eaf0647 100644 --- a/app/react/portainer/gitops/sources/ItemView/ItemView.tsx +++ b/app/react/portainer/gitops/sources/ItemView/ItemView.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from 'react'; -import { GitCommit, PenBoxIcon, Settings } from 'lucide-react'; +import { GitCommit, PenBoxIcon, Settings, UsersIcon } from 'lucide-react'; import { useIdParam } from '@/react/hooks/useIdParam'; +import { useCurrentUser } from '@/react/hooks/useUser'; import { PageHeader } from '@@/PageHeader'; import { Tab, WidgetTabs, useCurrentTabIndex } from '@@/Widget/WidgetTabs'; @@ -16,6 +17,7 @@ import { SettingsTab } from './SettingsTab/SettingsTab'; import { WorkflowsTab } from './WorkflowsTab'; import { SourceResourceHeader } from './SourceResourceHeader'; import { CountDot } from './CountDot'; +import { AccessTab } from './AccessTab'; const breadcrumbs = [ { label: 'GitOps Sources', link: 'portainer.gitops.sources' }, @@ -59,9 +61,10 @@ export function ItemView() { } function PageContent({ source }: { source: SourceDetail }) { - const [isEditingSettings, setIsEditingSettings] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const { isPureAdmin } = useCurrentUser(); - const tabs: Array = useMemo( + const tabs: Array = useMemo( () => [ { name: 'Settings', @@ -69,11 +72,12 @@ function PageContent({ source }: { source: SourceDetail }) { widget: ( ), selectedTabParam: 'settings', + isEditable: true, }, { name: ( @@ -86,11 +90,26 @@ function PageContent({ source }: { source: SourceDetail }) { widget: , selectedTabParam: 'workflows', }, + { + name: 'Access', + icon: UsersIcon, + widget: ( + + ), + selectedTabParam: 'accessTab', + isEditable: isPureAdmin, + }, ], - [isEditingSettings, source] + [isEditing, isPureAdmin, source] ); const currentTabIndex = useCurrentTabIndex(tabs); + const isTabEditable = tabs[currentTabIndex]?.isEditable; + return ( <>
- {currentTabIndex === 0 && - (isEditingSettings ? ( + {isTabEditable && + (isEditing ? ( Editing ) : ( diff --git a/app/react/portainer/gitops/sources/queries/useSource.ts b/app/react/portainer/gitops/sources/queries/useSource.ts index 298ecb6c9..cab8536a8 100644 --- a/app/react/portainer/gitops/sources/queries/useSource.ts +++ b/app/react/portainer/gitops/sources/queries/useSource.ts @@ -1,29 +1,128 @@ import { useQuery } from '@tanstack/react-query'; -import axios from '@/portainer/services/axios/axios'; +import { + type SourcesGitAuthInfo, + type SourcesConnectionInfo, + type SourcesAutoUpdateInfo, + type SourcesSourceDetail, + WorkflowsWorkflow, + WorkflowsWorkflowStatusObject, + WorkflowsStatus, + WorkflowsWorkflowPhaseStatus, + GittypesRepoConfig, + GittypesGitAuthentication, +} from '@api/types.gen'; +import { gitOpsSourceGet } from '@api/sdk.gen'; + import { withError } from '@/react-tools/react-query'; import { - SourcesAutoUpdateInfo, - SourcesConnectionInfo, -} from '@/react/portainer/generated-api/portainer/types.gen'; + type RepoConfigResponse, + type GitAuthenticationResponse, +} from '@/react/portainer/gitops/types'; +import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types'; import { Source } from '../types'; -import { Workflow } from '../../WorkflowsView/types'; +import { + Workflow, + WorkflowPhaseStatus, + WorkflowStatus, + WorkflowStatusObject, +} from '../../WorkflowsView/types'; import { sourceQueryKeys } from './query-keys'; +export type GitAuthInfo = SourcesGitAuthInfo; export type ConnectionInfo = SourcesConnectionInfo; export type AutoUpdateInfo = SourcesAutoUpdateInfo; -export interface SourceDetail extends Source { - connection: ConnectionInfo; - autoUpdate?: AutoUpdateInfo; - workflows: Workflow[]; -} +export type SourceDetail = Omit & { + workflows: Array; + usedBy: number; +}; async function getSource(id: Source['id']): Promise { - const { data } = await axios.get(`/gitops/sources/${id}`); - return data; + const { data } = await gitOpsSourceGet({ path: { id } }); + + return toSourceDetails(data); + + function toSourceDetails(source: SourcesSourceDetail): SourceDetail { + return { + ...source, + workflows: source.workflows?.map(toWorkflow) ?? [], + usedBy: source.usedBy ?? 0, + }; + + function toWorkflow(workflow: WorkflowsWorkflow): Workflow { + return { + ...workflow, + creationDate: workflow.creationDate ?? 0, + lastSyncDate: workflow.lastSyncDate ?? 0, + status: toWorkflowStatusObject(workflow.status), + gitConfig: toWorkflowGitConfig(workflow.gitConfig), + }; + } + + function toWorkflowStatusObject( + statusObj: WorkflowsWorkflowStatusObject + ): WorkflowStatusObject { + return { + ...statusObj, + source: toPhaseStatus(statusObj.source), + artifact: toPhaseStatus(statusObj.artifact), + target: toPhaseStatus(statusObj.target), + }; + } + } + + function toPhaseStatus( + phaseStatus: WorkflowsWorkflowPhaseStatus | undefined + ): WorkflowPhaseStatus { + return { + ...phaseStatus, + status: toWorkflowStatus(phaseStatus?.status), + }; + } + + function toWorkflowStatus( + status: WorkflowsStatus | undefined + ): WorkflowStatus { + if (!status) { + return 'unknown'; + } + + return status; + } + + function toWorkflowGitConfig( + gitConfig: GittypesRepoConfig | undefined + ): RepoConfigResponse | undefined { + if (!gitConfig) { + return undefined; + } + + return { + URL: gitConfig.URL ?? '', + ReferenceName: gitConfig.ReferenceName ?? '', + ConfigFilePath: gitConfig.ConfigFilePath ?? '', + ConfigHash: gitConfig.ConfigHash ?? '', + TLSSkipVerify: gitConfig.TLSSkipVerify ?? false, + Authentication: toGitAuthentication(gitConfig.Authentication), + }; + } + + function toGitAuthentication( + auth: GittypesGitAuthentication | undefined + ): GitAuthenticationResponse | undefined { + if (!auth) { + return undefined; + } + + return { + Username: auth.Username, + Password: auth.Password, + AuthorizationType: auth.AuthorizationType as AuthTypeOption | undefined, + }; + } } export function useSource(id: Source['id'] | undefined) { diff --git a/app/react/portainer/gitops/sources/queries/useUpdateSourceAccessMutation.ts b/app/react/portainer/gitops/sources/queries/useUpdateSourceAccessMutation.ts new file mode 100644 index 000000000..2a27ccc41 --- /dev/null +++ b/app/react/portainer/gitops/sources/queries/useUpdateSourceAccessMutation.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { type SourcesSourceAccess } from '@api/types.gen'; +import { gitOpsSourcesUpdateAccess } from '@api/sdk.gen'; + +import { withError } from '@/react-tools/react-query'; + +import { Source } from '../types'; + +import { sourceQueryKeys } from './query-keys'; + +export type UpdateSourceAccessPayload = SourcesSourceAccess; + +async function updateSourceAccess( + id: Source['id'], + payload: UpdateSourceAccessPayload +): Promise { + await gitOpsSourcesUpdateAccess({ + path: { id }, + body: payload, + }); +} + +export function useUpdateSourceAccessMutation(id: Source['id']) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: UpdateSourceAccessPayload) => + updateSourceAccess(id, payload), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: sourceQueryKeys.detail(id), + }), + ...withError('Unable to update source access'), + }); +} diff --git a/app/react/sidebar/AppDeliverySidebar.tsx b/app/react/sidebar/AppDeliverySidebar.tsx index 3b0e5abbf..9ad35981f 100644 --- a/app/react/sidebar/AppDeliverySidebar.tsx +++ b/app/react/sidebar/AppDeliverySidebar.tsx @@ -3,7 +3,7 @@ import { Database, GitBranch } from 'lucide-react'; import { SidebarItem } from './SidebarItem'; import { SidebarSection } from './SidebarSection'; -export function AppDeliverySidebar({ isAdmin }: { isAdmin: boolean }) { +export function AppDeliverySidebar() { return ( - {isAdmin && ( - - )} + + ); } diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx index c5575de2b..72997c260 100644 --- a/app/react/sidebar/Sidebar.tsx +++ b/app/react/sidebar/Sidebar.tsx @@ -71,7 +71,7 @@ function InnerSidebar() { - + {isAdmin && } diff --git a/app/types.ts b/app/types.ts index cb643fe0e..f5a9318be 100644 --- a/app/types.ts +++ b/app/types.ts @@ -19,6 +19,11 @@ export type WithRequiredProperties = Omit & export type ValueOf> = T[keyof T]; +export function stringEnumValues>( + e: T +): T[keyof T][] { + return Object.values(e) as T[keyof T][]; +} /** * Recursively makes all properties of a type optional, including nested objects. * Unlike TypeScript's built-in Partial which only affects top-level properties, diff --git a/dev/run_container.sh b/dev/run_container.sh index 53cd65f87..221703be5 100755 --- a/dev/run_container.sh +++ b/dev/run_container.sh @@ -19,4 +19,7 @@ docker run -d \ -e CSP=false \ --name portainer \ portainer/base \ - /app/portainer $PORTAINER_FLAGS + /app/portainer \ + --log-level=DEBUG \ + --no-setup-token \ + $PORTAINER_FLAGS