220 lines
6.9 KiB
Go
220 lines
6.9 KiB
Go
package workflows
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
portainer "github.com/portainer/portainer/api"
|
|
"github.com/portainer/portainer/api/dataservices"
|
|
"github.com/portainer/portainer/api/dataservices/source"
|
|
svc "github.com/portainer/portainer/api/gitops/workflows"
|
|
"github.com/portainer/portainer/api/http/security"
|
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
|
"github.com/portainer/portainer/api/set"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ErrNotFound indicates a workflow or one of its artifacts is not visible to the requesting user,
|
|
// either because it does not exist or because access has been filtered out.
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
// ErrResourceNotAccessible indicates sc cannot access a resource that does exist. Callers
|
|
// resolving a single artifact translate this to ErrNotFound to avoid leaking its existence.
|
|
var ErrResourceNotAccessible = errors.New("resource not accessible")
|
|
|
|
// fetchWorkflowByID returns the detail view of a single workflow, resolving each of its
|
|
// Artifacts to its backing Stack or EdgeStack and filtering out any the user cannot access.
|
|
// A workflow with zero artifacts to begin with is a valid, visible state and is returned as-is.
|
|
// ErrNotFound is returned when the workflow itself does not exist, or when it had artifacts but
|
|
// every one of them was filtered out (existence-hiding for the common single-artifact case).
|
|
func fetchWorkflowByID(
|
|
tx dataservices.DataStoreTx,
|
|
k8sFactory *cli.ClientFactory,
|
|
sc *security.RestrictedRequestContext,
|
|
workflowID portainer.WorkflowID,
|
|
) (*WorkflowDetail, error) {
|
|
wf, err := tx.Workflow().Read(workflowID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrNotFound, err)
|
|
}
|
|
|
|
sourceMap := map[portainer.SourceID]portainer.Source{}
|
|
if len(wf.Artifacts) > 0 {
|
|
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
|
|
sourceMap, err = svc.LoadWorkflowSources(tx, userContext, wf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
artifacts := make([]svc.ArtifactDetail, 0, len(wf.Artifacts))
|
|
for _, a := range wf.Artifacts {
|
|
detail, err := fetchArtifactDetail(tx, k8sFactory, sc, a, sourceMap)
|
|
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrResourceNotAccessible) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
artifacts = append(artifacts, *detail)
|
|
}
|
|
|
|
if len(wf.Artifacts) > 0 && len(artifacts) == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return &WorkflowDetail{
|
|
ID: int(wf.ID),
|
|
Name: wf.Name,
|
|
Artifacts: artifacts,
|
|
}, nil
|
|
}
|
|
|
|
// fetchArtifactDetail resolves a single Artifact to its backing Stack or EdgeStack.
|
|
func fetchArtifactDetail(
|
|
tx dataservices.DataStoreTx,
|
|
k8sFactory *cli.ClientFactory,
|
|
sc *security.RestrictedRequestContext,
|
|
artifact portainer.Artifact,
|
|
sourceMap map[portainer.SourceID]portainer.Source,
|
|
) (*svc.ArtifactDetail, error) {
|
|
switch {
|
|
case artifact.StackID != 0:
|
|
stack, err := tx.Stack().Read(artifact.StackID)
|
|
if tx.IsErrObjectNotFound(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fetchStackArtifact(tx, k8sFactory, sc, *stack, artifact, sourceMap)
|
|
|
|
case artifact.EdgeStackID != 0:
|
|
if !sc.IsAdmin {
|
|
return nil, ErrResourceNotAccessible
|
|
}
|
|
edgeStack, err := tx.EdgeStack().EdgeStack(artifact.EdgeStackID)
|
|
if tx.IsErrObjectNotFound(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fetchEdgeStackArtifact(tx, *edgeStack, artifact, sourceMap)
|
|
|
|
default:
|
|
return nil, ErrNotFound
|
|
}
|
|
}
|
|
|
|
// fetchStackArtifact resolves a stack-backed Artifact, applying the same endpoint/type-match and
|
|
// access checks as FetchWorkflows, scoped to a single stack.
|
|
func fetchStackArtifact(
|
|
tx dataservices.DataStoreTx,
|
|
k8sFactory *cli.ClientFactory,
|
|
sc *security.RestrictedRequestContext,
|
|
stack portainer.Stack,
|
|
artifact portainer.Artifact,
|
|
sourceMap map[portainer.SourceID]portainer.Source,
|
|
) (*svc.ArtifactDetail, error) {
|
|
endpointMap, err := svc.BuildEndpointMap(tx, []portainer.Stack{stack})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := checkStackAccessible(tx, k8sFactory, sc, stack, endpointMap, artifact, sourceMap); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sourcePhase, artifactPhase := svc.ArtifactPhases(artifact.Files, sourceMap)
|
|
|
|
detail := svc.MapStackToArtifactDetail(stack, artifact.Files, sourcePhase, artifactPhase)
|
|
return &detail, nil
|
|
}
|
|
|
|
// checkStackAccessible returns ErrResourceNotAccessible if sc cannot access stack, either because
|
|
// its endpoint's Kubernetes namespace/source access is insufficient or because Docker's
|
|
// resource-control-based UAC filters it out.
|
|
func checkStackAccessible(
|
|
tx dataservices.DataStoreTx,
|
|
k8sFactory *cli.ClientFactory,
|
|
sc *security.RestrictedRequestContext,
|
|
stack portainer.Stack,
|
|
endpointMap map[portainer.EndpointID]portainer.Endpoint,
|
|
artifact portainer.Artifact,
|
|
sourceMap map[portainer.SourceID]portainer.Source,
|
|
) error {
|
|
if stack.Type != portainer.KubernetesStack {
|
|
accessible, err := svc.FilterDockerStacksByAccess(tx, []portainer.Stack{stack}, sc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(accessible) == 0 {
|
|
return ErrResourceNotAccessible
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
ep, epOk := endpointMap[stack.EndpointID]
|
|
if !epOk {
|
|
return ErrResourceNotAccessible
|
|
}
|
|
|
|
access, err := svc.ResolveKubeAccess(k8sFactory, sc, &ep)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("context", "checkStackAccessible").Int("endpoint_id", int(ep.ID)).Msg("Failed to resolve kube access for endpoint, filtering artifact")
|
|
return ErrResourceNotAccessible
|
|
}
|
|
|
|
if (!access.IsKubeAdmin && !slices.Contains(access.NonAdminNamespaces, stack.Namespace)) || !HasAccessibleSource(artifact.Files, sourceMap) {
|
|
return ErrResourceNotAccessible
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchEdgeStackArtifact resolves an edge-stack-backed Artifact. The caller (fetchArtifactDetail)
|
|
// has already gated access via sc.IsAdmin, so edge stacks are visible to admins only.
|
|
func fetchEdgeStackArtifact(
|
|
tx dataservices.DataStoreTx,
|
|
edgeStack portainer.EdgeStack,
|
|
artifact portainer.Artifact,
|
|
sourceMap map[portainer.SourceID]portainer.Source,
|
|
) (*svc.ArtifactDetail, error) {
|
|
statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
groupIDSet := set.ToSet(edgeStack.EdgeGroups)
|
|
edgeGroups, err := tx.EdgeGroup().ReadAll(func(g portainer.EdgeGroup) bool {
|
|
return groupIDSet.Contains(g.ID)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
groupEndpoints, err := svc.BuildGroupEndpoints(tx, edgeGroups)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sourcePhase, artifactPhase := svc.ArtifactPhases(artifact.Files, sourceMap)
|
|
|
|
detail := svc.MapEdgeStackToArtifactDetail(edgeStack, artifact.Files, statuses, groupEndpoints, sourcePhase, artifactPhase)
|
|
return &detail, nil
|
|
}
|
|
|
|
// HasAccessibleSource reports whether any of the artifact's files has a source visible in
|
|
// sourceMap.
|
|
func HasAccessibleSource(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) bool {
|
|
return slices.ContainsFunc(files, func(f portainer.ArtifactFile) bool {
|
|
_, ok := sourceMap[f.SourceID]
|
|
return ok
|
|
})
|
|
}
|