chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
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 {
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 {
return false
}
if context.IsAdmin() {
return true
}
if source.OwnerID != 0 && source.OwnerID == context.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 {
return false
}
userTeams := context.TeamMemberships()
if context.IsAdmin() || source.Public {
return true
}
if source.AdministratorsOnly {
return false
}
if slices.Contains(source.UserAccesses, context.ID()) {
return true
}
if len(userTeams) == 0 || len(source.TeamAccesses) == 0 {
return false
}
sTeams := set.ToSet(source.TeamAccesses)
uTeams := set.ToSet(slicesx.Map(userTeams, func(u portainer.TeamMembership) portainer.TeamID { return u.TeamID }))
return set.Intersection(sTeams, uTeams).Len() != 0
}
// enforceUniqueGitSource validates there are no other git sources with the same URL and credentials
// It ignores itself
func enforceUniqueGitSource(tx ServiceTx, src *portainer.Source) error {
if src.Type != portainer.SourceTypeGit || src.Git == nil {
return nil
}
normalized, err := normalizeGitSource(src)
if err != nil {
return err
}
existing, err := tx.base.ReadAll(func(s portainer.Source) bool {
if src.ID == s.ID {
return false
}
n, err := normalizeGitSource(&s)
if err != nil {
return false
}
return normalized.Equal(n)
})
if err != nil {
return err
}
if len(existing) > 0 {
return ErrDuplicateSource
}
return nil
}
@@ -0,0 +1,43 @@
package source
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
type normalizedGitSource struct {
url string
username string
password string
}
func (a *normalizedGitSource) Equal(b *normalizedGitSource) bool {
return a != nil && b != nil &&
a.url == b.url &&
a.username == b.username &&
a.password == b.password
}
// normalize git source to a lighter object used to compare sources together
func normalizeGitSource(src *portainer.Source) (*normalizedGitSource, error) {
if src == nil || src.Type != portainer.SourceTypeGit || src.Git == nil {
return nil, ErrInvalidSource
}
url, err := gittypes.NormalizeURL(gittypes.SanitizeURL(src.Git.URL))
if err != nil {
return nil, err
}
username, password := "", ""
if src.Git.Authentication != nil {
username = src.Git.Authentication.Username
password = src.Git.Authentication.Password
}
return &normalizedGitSource{
url: url,
username: username,
password: password,
}, nil
}
+74
View File
@@ -0,0 +1,74 @@
package source
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
// Sanitize the source URL and enforce fields values based on user context
func sanitizeGitSource(source *portainer.Source) error {
if source == nil {
return ErrInvalidSource
}
if source.Type != portainer.SourceTypeGit {
return nil
}
if source.Git == nil {
return ErrInvalidSource
}
var err error
source.Git.URL, err = gittypes.NormalizeURL(gittypes.SanitizeURL(source.Git.URL))
if err != nil {
return err
}
return nil
}
func sanitizeAccesses(ctx UserContext, newValues *portainer.Source, previousValues *portainer.Source) error {
if newValues == nil {
return ErrInvalidSource
}
if ctx.IsAdmin() {
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.ID()}
if newValues.Public {
userAccesses = []portainer.UserID{}
}
newValues.UserAccesses = userAccesses
newValues.TeamAccesses = []portainer.TeamID{}
newValues.AdministratorsOnly = false
newValues.OwnerID = ctx.ID()
return nil
}
@@ -0,0 +1,72 @@
package source
import (
"testing"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/stretchr/testify/require"
)
type vFn func(new *portainer.Source, old *portainer.Source, err error)
func testUAC(
t *testing.T,
userContext UserContext,
new *portainer.Source,
old *portainer.Source,
validationFuncs ...vFn,
) {
t.Helper()
err := sanitizeAccesses(userContext, new, old)
for _, validate := range validationFuncs {
validate(new, old, err)
}
}
func Test_SanitizeAccesses_Admin(t *testing.T) {
errInvalidSource := func(_, _ *portainer.Source, err error) {
t.Helper()
require.ErrorIs(t, err, ErrInvalidSource)
}
noError := func(_, _ *portainer.Source, err error) { t.Helper(); require.NoError(t, err) }
noOwner := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Zero(t, new.OwnerID) }
emptyUsers := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.UserAccesses) }
emptyTeams := func(new, _ *portainer.Source, _ error) { t.Helper(); require.Empty(t, new.TeamAccesses) }
public := func(v bool) func(new, _ *portainer.Source, _ error) {
return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.Public) }
}
adminOnly := func(v bool) func(new, _ *portainer.Source, _ error) {
return func(new, _ *portainer.Source, _ error) { t.Helper(); require.Equal(t, v, new.AdministratorsOnly) }
}
adminUserContext := NewUserContext(&portainer.User{Role: portainer.AdministratorRole}, []portainer.TeamMembership{})
test := func(new *portainer.Source, old *portainer.Source, validationFuncs ...vFn) {
t.Helper()
testUAC(t, adminUserContext, new, old, validationFuncs...)
}
test(nil, nil, errInvalidSource)
test(&portainer.Source{}, nil, noError)
test(&portainer.Source{Git: &gittypes.GitSource{}}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.GitSource{}, Public: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(false), noOwner, public(true),
)
test(&portainer.Source{Git: &gittypes.GitSource{}, AdministratorsOnly: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.GitSource{}, AdministratorsOnly: true, Public: true}, nil,
noError, emptyUsers, emptyTeams, adminOnly(true), noOwner, public(false),
)
test(&portainer.Source{Git: &gittypes.GitSource{}, 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{})
// }
+106
View File
@@ -0,0 +1,106 @@
package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// BucketName represents the name of the bucket where this service stores data.
const BucketName = "sources"
// Service represents a service for managing GitOps source data.
type Service struct {
base dataservices.BaseDataService[portainer.Source, portainer.SourceID]
}
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
}
return &Service{
base: dataservices.BaseDataService[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: connection,
},
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
base: dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]{
Bucket: BucketName,
Connection: service.base.Connection,
Tx: tx,
},
}
}
// Create creates a new 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
}
+204
View File
@@ -0,0 +1,204 @@
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 {
base dataservices.BaseDataServiceTx[portainer.Source, portainer.SourceID]
}
// Create creates a new source.
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)
return int(source.ID), source
},
)
}
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.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.GitSource{
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
}
+39
View File
@@ -0,0 +1,39 @@
package source
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
type UserContext = dataservices.SourceServiceUserContext
type userContext struct {
id portainer.UserID
role portainer.UserRole
memberships []portainer.TeamMembership
}
func (uc userContext) ID() portainer.UserID {
return uc.id
}
func (uc userContext) TeamMemberships() []portainer.TeamMembership {
return uc.memberships
}
func (uc userContext) IsAdmin() bool {
return uc.role == portainer.AdministratorRole
}
// 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{id: user.ID, role: user.Role, memberships: userMemberships}
}
@@ -0,0 +1,97 @@
package source
// var adminUserContext = InsecureNewAdminContext()
// func newSourceWithAuth(url, username, password string) *portainer.Source {
// return &portainer.Source{
// Type: portainer.SourceTypeGit,
// Git: &gittypes.RepoConfig{
// URL: url,
// Authentication: &gittypes.GitAuthentication{
// Username: username,
// Password: password,
// },
// },
// }
// }
// func newAuthlessSource(url string) *portainer.Source {
// return &portainer.Source{
// Type: portainer.SourceTypeGit,
// Git: &gittypes.RepoConfig{URL: url},
// }
// }
// func validateUniqueSourceInStore(t *testing.T, tx ServiceTx, url, username, password string, sourceID portainer.SourceID) bool {
// t.Helper()
// var isUnique bool
// require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
// var err error
// isUnique, err =// enforceUniqueGitSource(tx, url, username, password, sourceID)
// return err
// }))
// return isUnique
// }
// func TestValidateUniqueSource_SameURLAndCreds_IsDuplicate(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
// }))
// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
// }
// func TestValidateUniqueSource_SameURLDifferentCreds_IsUnique(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret"))
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "bob", "other", 0))
// }
// func TestValidateUniqueSource_TwoAuthlessSameURL_IsDuplicate(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git"))
// }))
// require.False(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "", "", 0))
// }
// func TestValidateUniqueSource_AuthlessVsAuthenticated_IsUnique(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// return tx.Source().Create(adminUserContext, newAuthlessSource("https://github.com/org/repo.git"))
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", 0))
// }
// func TestValidateUniqueSource_ExcludesSelf(t *testing.T) {
// t.Parallel()
// _, store := datastore.MustNewTestStore(t, false, true)
// var srcID portainer.SourceID
// require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// src := newSourceWithAuth("https://github.com/org/repo.git", "alice", "secret")
// if err := tx.Source().Create(adminUserContext, src); err != nil {
// return err
// }
// srcID = src.ID
// return nil
// }))
// require.True(t, validateUniqueSourceInStore(t, store, "https://github.com/org/repo.git", "alice", "secret", srcID))
// }