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

Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
This commit is contained in:
LP B
2026-06-23 01:38:21 +02:00
committed by GitHub
parent f4ac9bae2e
commit 272d3a47ae
116 changed files with 2634 additions and 942 deletions
+25 -100
View File
@@ -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
}