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
+24 -18
View File
@@ -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,
}
}
@@ -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) {
+14 -3
View File
@@ -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)
}
@@ -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
+46 -3
View File
@@ -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,
}
}
+7 -5
View File
@@ -42,13 +42,15 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
authenticatedRouter.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
authenticatedRouter.Handle("/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
}
+20 -25
View File
@@ -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
}
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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))
@@ -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
@@ -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)
}
@@ -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
}
}
+15 -19
View File
@@ -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)
@@ -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