diff --git a/src/server/middleware/immutable/deletemf.go b/src/server/middleware/immutable/deletemf.go new file mode 100644 index 000000000..b200bc0cd --- /dev/null +++ b/src/server/middleware/immutable/deletemf.go @@ -0,0 +1,101 @@ +package immutable + +import ( + "errors" + "fmt" + common_util "github.com/goharbor/harbor/src/common/utils" + internal_errors "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/pkg/art" + "github.com/goharbor/harbor/src/pkg/artifact" + "github.com/goharbor/harbor/src/pkg/immutabletag/match/rule" + "github.com/goharbor/harbor/src/pkg/q" + "github.com/goharbor/harbor/src/pkg/repository" + "github.com/goharbor/harbor/src/pkg/tag" + "github.com/goharbor/harbor/src/server/middleware" + "net/http" +) + +// MiddlewareDelete ... +func MiddlewareDelete() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if err := handleDelete(req); err != nil { + var e *ErrImmutable + if errors.As(err, &e) { + pkgE := internal_errors.New(e).WithCode(internal_errors.PreconditionCode) + msg := internal_errors.NewErrs(pkgE).Error() + http.Error(rw, msg, http.StatusPreconditionFailed) + return + } + pkgE := internal_errors.New(fmt.Errorf("error occurred when to handle request in immutable handler: %v", err)).WithCode(internal_errors.GeneralCode) + msg := internal_errors.NewErrs(pkgE).Error() + http.Error(rw, msg, http.StatusInternalServerError) + } + next.ServeHTTP(rw, req) + }) + } +} + +// handleDelete ... +func handleDelete(req *http.Request) error { + mf, ok := middleware.ManifestInfoFromContext(req.Context()) + if !ok { + return errors.New("cannot get the manifest information from request context") + } + + _, repoName := common_util.ParseRepository(mf.Repository) + total, repos, err := repository.Mgr.List(req.Context(), &q.Query{ + Keywords: map[string]interface{}{ + "Name": mf.Repository, + }, + }) + if err != nil { + return err + } + if total == 0 { + return nil + } + + total, afs, err := artifact.Mgr.List(req.Context(), &q.Query{ + Keywords: map[string]interface{}{ + "ProjectID": mf.ProjectID, + "RepositoryID": repos[0].RepositoryID, + "Digest": mf.Digest, + }, + }) + if err != nil { + return err + } + if total == 0 { + return nil + } + + total, tags, err := tag.Mgr.List(req.Context(), &q.Query{ + Keywords: map[string]interface{}{ + "ArtifactID": afs[0].ID, + }, + }) + if err != nil { + return err + } + if total == 0 { + return nil + } + + for _, tag := range tags { + var matched bool + matched, err = rule.NewRuleMatcher(mf.ProjectID).Match(art.Candidate{ + Repository: repoName, + Tag: tag.Name, + NamespaceID: mf.ProjectID, + }) + if err != nil { + return err + } + if matched { + return NewErrImmutable(repoName, tag.Name) + } + } + + return nil +} diff --git a/src/server/middleware/immutable/deletemf_test.go b/src/server/middleware/immutable/deletemf_test.go new file mode 100644 index 000000000..f9933ba67 --- /dev/null +++ b/src/server/middleware/immutable/deletemf_test.go @@ -0,0 +1,3 @@ +package immutable + +// Tests are in the push_mf_test diff --git a/src/server/middleware/immutable/error.go b/src/server/middleware/immutable/error.go new file mode 100644 index 000000000..39478bd72 --- /dev/null +++ b/src/server/middleware/immutable/error.go @@ -0,0 +1,27 @@ +package immutable + +import "fmt" + +// ErrImmutable ... +type ErrImmutable struct { + repo string + tag string +} + +// Error ... +func (ei *ErrImmutable) Error() string { + return fmt.Sprintf("Failed to process request due to '%s:%s' configured as immutable.", ei.repo, ei.tag) +} + +// Unwrap ... +func (ei *ErrImmutable) Unwrap() error { + return nil +} + +// NewErrImmutable ... +func NewErrImmutable(msg, tag string) error { + return &ErrImmutable{ + repo: msg, + tag: tag, + } +} diff --git a/src/server/middleware/immutable/pushmf.go b/src/server/middleware/immutable/pushmf.go new file mode 100644 index 000000000..655f542bd --- /dev/null +++ b/src/server/middleware/immutable/pushmf.go @@ -0,0 +1,111 @@ +package immutable + +import ( + "errors" + "fmt" + common_util "github.com/goharbor/harbor/src/common/utils" + internal_errors "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/pkg/art" + "github.com/goharbor/harbor/src/pkg/artifact" + "github.com/goharbor/harbor/src/pkg/immutabletag/match/rule" + "github.com/goharbor/harbor/src/pkg/q" + "github.com/goharbor/harbor/src/pkg/repository" + "github.com/goharbor/harbor/src/pkg/tag" + "github.com/goharbor/harbor/src/server/middleware" + "net/http" +) + +// MiddlewarePush ... +func MiddlewarePush() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if err := handlePush(req); err != nil { + var e *ErrImmutable + if errors.As(err, &e) { + pkgE := internal_errors.New(e).WithCode(internal_errors.PreconditionCode) + msg := internal_errors.NewErrs(pkgE).Error() + http.Error(rw, msg, http.StatusPreconditionFailed) + return + } + pkgE := internal_errors.New(fmt.Errorf("error occurred when to handle request in immutable handler: %v", err)).WithCode(internal_errors.GeneralCode) + msg := internal_errors.NewErrs(pkgE).Error() + http.Error(rw, msg, http.StatusInternalServerError) + } + next.ServeHTTP(rw, req) + }) + } +} + +// handlePush ... +// If the pushing image matched by any of immutable rule, will have to whether it is the first time to push it, +// as the immutable rule only impacts the existing tag. +func handlePush(req *http.Request) error { + mf, ok := middleware.ManifestInfoFromContext(req.Context()) + if !ok { + return errors.New("cannot get the manifest information from request context") + } + + _, repoName := common_util.ParseRepository(mf.Repository) + var matched bool + matched, err := rule.NewRuleMatcher(mf.ProjectID).Match(art.Candidate{ + Repository: repoName, + Tag: mf.Tag, + NamespaceID: mf.ProjectID, + }) + if err != nil { + return err + } + if !matched { + return nil + } + + // match repository ... + total, repos, err := repository.Mgr.List(req.Context(), &q.Query{ + Keywords: map[string]interface{}{ + "Name": mf.Repository, + }, + }) + if err != nil { + return err + } + if total == 0 { + return nil + } + + // match artifacts ... + total, afs, err := artifact.Mgr.List(req.Context(), &q.Query{ + Keywords: map[string]interface{}{ + "ProjectID": mf.ProjectID, + "RepositoryID": repos[0].RepositoryID, + }, + }) + if err != nil { + return err + } + if total == 0 { + return nil + } + + // match tags ... + for _, af := range afs { + total, tags, err := tag.Mgr.List(req.Context(), &q.Query{ + Keywords: map[string]interface{}{ + "ArtifactID": af.ID, + }, + }) + if err != nil { + return err + } + if total == 0 { + continue + } + for _, tag := range tags { + // push a existing immutable tag, reject the request + if tag.Name == mf.Tag { + return NewErrImmutable(repoName, mf.Tag) + } + } + } + + return nil +} diff --git a/src/server/middleware/immutable/pushmf_test.go b/src/server/middleware/immutable/pushmf_test.go new file mode 100644 index 000000000..29a8e408e --- /dev/null +++ b/src/server/middleware/immutable/pushmf_test.go @@ -0,0 +1,223 @@ +package immutable + +import ( + "context" + "github.com/goharbor/harbor/src/core/middlewares/util" + internal_orm "github.com/goharbor/harbor/src/internal/orm" + "github.com/goharbor/harbor/src/pkg/artifact" + "github.com/goharbor/harbor/src/pkg/repository" + "github.com/goharbor/harbor/src/server/middleware" + "github.com/opencontainers/go-digest" + "time" + + "fmt" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/pkg/immutabletag" + immu_model "github.com/goharbor/harbor/src/pkg/immutabletag/model" + "github.com/goharbor/harbor/src/pkg/tag" + tag_model "github.com/goharbor/harbor/src/pkg/tag/model/tag" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +type HandlerSuite struct { + suite.Suite +} + +func doPutManifestRequest(projectID int64, projectName, name, tag, dgt string, next ...http.HandlerFunc) int { + repository := fmt.Sprintf("%s/%s", projectName, name) + + url := fmt.Sprintf("/v2/%s/manifests/%s", repository, tag) + req, _ := http.NewRequest("PUT", url, nil) + + mfInfo := &middleware.ManifestInfo{ + ProjectID: projectID, + Repository: repository, + Tag: tag, + Digest: dgt, + } + rr := httptest.NewRecorder() + + var n http.HandlerFunc + if len(next) > 0 { + n = next[0] + } else { + n = func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusCreated) + } + } + *req = *(req.WithContext(internal_orm.NewContext(context.TODO(), dao.GetOrmer()))) + *req = *(req.WithContext(middleware.NewManifestInfoContext(req.Context(), mfInfo))) + h := MiddlewarePush()(n) + h.ServeHTTP(util.NewCustomResponseWriter(rr), req) + + return rr.Code +} + +func doDeleteManifestRequest(projectID int64, projectName, name, tag, dgt string, next ...http.HandlerFunc) int { + repository := fmt.Sprintf("%s/%s", projectName, name) + + url := fmt.Sprintf("/v2/%s/manifests/%s", repository, tag) + req, _ := http.NewRequest("DELETE", url, nil) + + mfInfo := &middleware.ManifestInfo{ + ProjectID: projectID, + Repository: repository, + Tag: tag, + Digest: dgt, + } + rr := httptest.NewRecorder() + + var n http.HandlerFunc + if len(next) > 0 { + n = next[0] + } else { + n = func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusCreated) + } + } + *req = *(req.WithContext(internal_orm.NewContext(context.TODO(), dao.GetOrmer()))) + *req = *(req.WithContext(middleware.NewManifestInfoContext(req.Context(), mfInfo))) + h := MiddlewareDelete()(n) + h.ServeHTTP(util.NewCustomResponseWriter(rr), req) + + return rr.Code +} + +func randomString(n int) string { + const letterBytes = "abcdefghijklmnopqrstuvwxyz" + + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + + return string(b) +} + +func (suite *HandlerSuite) addProject(projectName string) int64 { + projectID, err := dao.AddProject(models.Project{ + Name: projectName, + OwnerID: 1, + }) + suite.Nil(err, fmt.Sprintf("Add project failed for %s", projectName)) + return projectID +} + +func (suite *HandlerSuite) addArt(ctx context.Context, pid, repositoryID int64, dgt string) int64 { + af := &artifact.Artifact{ + Type: "Docker-Image", + ProjectID: pid, + RepositoryID: repositoryID, + Digest: dgt, + Size: 1024, + PushTime: time.Now(), + PullTime: time.Now(), + } + afid, err := artifact.Mgr.Create(ctx, af) + suite.Nil(err, fmt.Sprintf("Add artifact failed for %d", repositoryID)) + return afid +} + +func (suite *HandlerSuite) addRepo(ctx context.Context, pid int64, repo string) int64 { + repoRec := &models.RepoRecord{ + Name: repo, + ProjectID: pid, + } + repoid, err := repository.Mgr.Create(ctx, repoRec) + suite.Nil(err, fmt.Sprintf("Add repository failed for %s", repo)) + return repoid +} + +func (suite *HandlerSuite) addTags(ctx context.Context, repoid int64, afid int64, name string) int64 { + t := &tag_model.Tag{ + RepositoryID: repoid, + ArtifactID: afid, + Name: name, + PushTime: time.Time{}, + PullTime: time.Time{}, + } + tid, err := tag.Mgr.Create(ctx, t) + suite.Nil(err, fmt.Sprintf("Add artifact failed for %s", name)) + return tid +} + +func (suite *HandlerSuite) addImmutableRule(pid int64) int64 { + metadata := &immu_model.Metadata{ + ProjectID: pid, + Priority: 1, + Action: "immutable", + Template: "immutable_template", + TagSelectors: []*immu_model.Selector{ + { + Kind: "doublestar", + Decoration: "matches", + Pattern: "release-**", + }, + }, + ScopeSelectors: map[string][]*immu_model.Selector{ + "repository": { + { + Kind: "doublestar", + Decoration: "repoMatches", + Pattern: "**", + }, + }, + }, + } + id, err := immutabletag.ImmuCtr.CreateImmutableRule(metadata) + require.NoError(suite.T(), err, "nil error expected but got %s", err) + return id +} + +func (suite *HandlerSuite) TestPutDeleteManifestCreated() { + projectName := randomString(5) + repoName := projectName + "/photon" + dgt := digest.FromString(randomString(15)).String() + ctx := internal_orm.NewContext(context.TODO(), dao.GetOrmer()) + + projectID := suite.addProject(projectName) + immuRuleID := suite.addImmutableRule(projectID) + repoID := suite.addRepo(ctx, projectID, repoName) + afID := suite.addArt(ctx, projectID, repoID, dgt) + tagID := suite.addTags(ctx, repoID, afID, "release-1.10") + + defer func() { + dao.DeleteProject(projectID) + artifact.Mgr.Delete(ctx, afID) + repository.Mgr.Delete(ctx, repoID) + tag.Mgr.Delete(ctx, tagID) + immutabletag.ImmuCtr.DeleteImmutableRule(immuRuleID) + }() + + code1 := doPutManifestRequest(projectID, projectName, "photon", "release-1.10", dgt) + suite.Equal(http.StatusPreconditionFailed, code1) + + code2 := doPutManifestRequest(projectID, projectName, "photon", "latest", dgt) + suite.Equal(http.StatusCreated, code2) + + code3 := doDeleteManifestRequest(projectID, projectName, "photon", "release-1.10", dgt) + suite.Equal(http.StatusPreconditionFailed, code3) + + code4 := doDeleteManifestRequest(projectID, projectName, "photon", "latest", dgt) + suite.Equal(http.StatusPreconditionFailed, code4) + +} + +func TestMain(m *testing.M) { + dao.PrepareTestForPostgresSQL() + + if result := m.Run(); result != 0 { + os.Exit(result) + } +} + +func TestRunHandlerSuite(t *testing.T) { + suite.Run(t, new(HandlerSuite)) +} diff --git a/src/server/registry/handler.go b/src/server/registry/handler.go index e3a8d6b56..65de1e047 100644 --- a/src/server/registry/handler.go +++ b/src/server/registry/handler.go @@ -23,6 +23,7 @@ import ( pkg_repo "github.com/goharbor/harbor/src/pkg/repository" pkg_tag "github.com/goharbor/harbor/src/pkg/tag" "github.com/goharbor/harbor/src/server/middleware" + "github.com/goharbor/harbor/src/server/middleware/immutable" "github.com/goharbor/harbor/src/server/middleware/manifestinfo" "github.com/goharbor/harbor/src/server/middleware/readonly" "github.com/goharbor/harbor/src/server/middleware/regtoken" @@ -54,8 +55,8 @@ func New(url *url.URL) http.Handler { manifestRouter := rootRouter.Path("/v2/{name:.*}/manifests/{reference}").Subrouter() manifestRouter.NewRoute().Methods(http.MethodGet).Handler(middleware.WithMiddlewares(manifest.NewHandler(project.Mgr, proxy), manifestinfo.Middleware(), regtoken.Middleware())) manifestRouter.NewRoute().Methods(http.MethodHead).Handler(manifest.NewHandler(project.Mgr, proxy)) - manifestRouter.NewRoute().Methods(http.MethodPut).Handler(middleware.WithMiddlewares(manifest.NewHandler(project.Mgr, proxy), readonly.Middleware())) - manifestRouter.NewRoute().Methods(http.MethodDelete).Handler(middleware.WithMiddlewares(manifest.NewHandler(project.Mgr, proxy), readonly.Middleware())) + manifestRouter.NewRoute().Methods(http.MethodPut).Handler(middleware.WithMiddlewares(manifest.NewHandler(project.Mgr, proxy), readonly.Middleware(), manifestinfo.Middleware(), immutable.MiddlewarePush())) + manifestRouter.NewRoute().Methods(http.MethodDelete).Handler(middleware.WithMiddlewares(manifest.NewHandler(project.Mgr, proxy), readonly.Middleware(), manifestinfo.Middleware(), immutable.MiddlewareDelete())) // handle blob // as we need to apply middleware to the blob requests, so create a sub router to handle the blob APIs