diff --git a/src/core/middlewares/chain.go b/src/core/middlewares/chain.go index 822dc0c63..79617c735 100644 --- a/src/core/middlewares/chain.go +++ b/src/core/middlewares/chain.go @@ -21,6 +21,7 @@ import ( "github.com/goharbor/harbor/src/core/middlewares/chart" "github.com/goharbor/harbor/src/core/middlewares/contenttrust" "github.com/goharbor/harbor/src/core/middlewares/countquota" + "github.com/goharbor/harbor/src/core/middlewares/immutable" "github.com/goharbor/harbor/src/core/middlewares/listrepo" "github.com/goharbor/harbor/src/core/middlewares/multiplmanifest" "github.com/goharbor/harbor/src/core/middlewares/readonly" @@ -70,6 +71,7 @@ func (b *DefaultCreator) geMiddleware(mName string) alice.Constructor { VULNERABLE: func(next http.Handler) http.Handler { return vulnerable.New(next) }, SIZEQUOTA: func(next http.Handler) http.Handler { return sizequota.New(next) }, COUNTQUOTA: func(next http.Handler) http.Handler { return countquota.New(next) }, + IMMUTABLE: func(next http.Handler) http.Handler { return immutable.New(next) }, } return middlewares[mName] } diff --git a/src/core/middlewares/config.go b/src/core/middlewares/config.go index 8f0dcb3c0..adc2a75c4 100644 --- a/src/core/middlewares/config.go +++ b/src/core/middlewares/config.go @@ -25,13 +25,14 @@ const ( VULNERABLE = "vulnerable" SIZEQUOTA = "sizequota" COUNTQUOTA = "countquota" + IMMUTABLE = "immutable" ) // ChartMiddlewares middlewares for chart server var ChartMiddlewares = []string{CHART} // Middlewares with sequential organization -var Middlewares = []string{READONLY, URL, MUITIPLEMANIFEST, LISTREPO, CONTENTTRUST, VULNERABLE, SIZEQUOTA, COUNTQUOTA} +var Middlewares = []string{READONLY, URL, MUITIPLEMANIFEST, LISTREPO, CONTENTTRUST, VULNERABLE, SIZEQUOTA, IMMUTABLE, COUNTQUOTA} // MiddlewaresLocal ... var MiddlewaresLocal = []string{SIZEQUOTA, COUNTQUOTA} diff --git a/src/core/middlewares/immutable/handler.go b/src/core/middlewares/immutable/handler.go new file mode 100644 index 000000000..0566d9df0 --- /dev/null +++ b/src/core/middlewares/immutable/handler.go @@ -0,0 +1,93 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package immutable + +import ( + "fmt" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + common_util "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/middlewares/util" + "github.com/goharbor/harbor/src/pkg/art" + "github.com/goharbor/harbor/src/pkg/immutabletag/match/rule" + "net/http" +) + +type immutableHandler struct { + next http.Handler +} + +// New ... +func New(next http.Handler) http.Handler { + return &immutableHandler{ + next: next, + } +} + +// ServeHTTP ... +func (rh immutableHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if match, _, _ := util.MatchPushManifest(req); !match { + rh.next.ServeHTTP(rw, req) + return + } + info, ok := util.ManifestInfoFromContext(req.Context()) + if !ok { + var err error + info, err = util.ParseManifestInfoFromPath(req) + if err != nil { + log.Error(err) + rh.next.ServeHTTP(rw, req) + return + } + } + + _, repoName := common_util.ParseRepository(info.Repository) + matched, err := rule.NewRuleMatcher(info.ProjectID).Match(art.Candidate{ + Repository: repoName, + Tag: info.Tag, + NamespaceID: info.ProjectID, + }) + if err != nil { + log.Error(err) + rh.next.ServeHTTP(rw, req) + return + } + if !matched { + rh.next.ServeHTTP(rw, req) + return + } + + artifactQuery := &models.ArtifactQuery{ + PID: info.ProjectID, + Repo: info.Repository, + Tag: info.Tag, + } + afs, err := dao.ListArtifacts(artifactQuery) + if err != nil { + log.Error(err) + rh.next.ServeHTTP(rw, req) + return + } + if len(afs) == 0 { + rh.next.ServeHTTP(rw, req) + return + } + + // rule matched and non-existent is a immutable tag + http.Error(rw, util.MarshalError("DENIED", + fmt.Sprintf("The tag:%s:%s is immutable, cannot be overwrite.", info.Repository, info.Tag)), http.StatusPreconditionFailed) + return +} diff --git a/src/core/middlewares/immutable/handler_test.go b/src/core/middlewares/immutable/handler_test.go new file mode 100644 index 000000000..690acb1d4 --- /dev/null +++ b/src/core/middlewares/immutable/handler_test.go @@ -0,0 +1,151 @@ +package immutable + +import ( + "github.com/docker/distribution" + "github.com/goharbor/harbor/src/core/middlewares/util" + "github.com/opencontainers/go-digest" + + "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/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 := &util.ManifestInfo{ + ProjectID: projectID, + Repository: repository, + Tag: tag, + Digest: dgt, + References: []distribution.Descriptor{ + {Digest: digest.FromString(randomString(15))}, + {Digest: digest.FromString(randomString(15))}, + }, + } + ctx := util.NewManifestInfoContext(req.Context(), mfInfo) + 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) + } + } + + h := New(http.HandlerFunc(n)) + h.ServeHTTP(util.NewCustomResponseWriter(rr), req.WithContext(ctx)) + + 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(pid int64, repo string, tag string) int64 { + afid, err := dao.AddArtifact(&models.Artifact{ + PID: pid, + Repo: repo, + Tag: tag, + Digest: digest.FromString(randomString(15)).String(), + Kind: "Docker-Image", + }) + suite.Nil(err, fmt.Sprintf("Add artifact failed for %s", repo)) + return afid +} + +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) TestPutManifestCreated() { + projectName := randomString(5) + + projectID := suite.addProject(projectName) + immuRuleID := suite.addImmutableRule(projectID) + afID := suite.addArt(projectID, projectName+"/photon", "release-1.10") + defer func() { + dao.DeleteProject(projectID) + dao.DeleteArtifact(afID) + immutabletag.ImmuCtr.DeleteImmutableRule(immuRuleID) + }() + + dgt := digest.FromString(randomString(15)).String() + 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) + +} + +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/pkg/immutabletag/match/rule/match_test.go b/src/pkg/immutabletag/match/rule/match_test.go index 05992e3fe..7ad4daa10 100644 --- a/src/pkg/immutabletag/match/rule/match_test.go +++ b/src/pkg/immutabletag/match/rule/match_test.go @@ -1,15 +1,15 @@ package rule import ( - "github.com/goharbor/harbor/src/common/utils/test" + "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/pkg/art" "github.com/goharbor/harbor/src/pkg/immutabletag" "github.com/goharbor/harbor/src/pkg/immutabletag/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "os" "testing" - "time" ) // MatchTestSuite ... @@ -25,7 +25,6 @@ type MatchTestSuite struct { // SetupSuite ... func (s *MatchTestSuite) SetupSuite() { - test.InitDatabaseFromEnv() s.t = s.T() s.assert = assert.New(s.t) s.require = require.New(s.t) @@ -34,34 +33,31 @@ func (s *MatchTestSuite) SetupSuite() { func (s *MatchTestSuite) TestImmuMatch() { rule := &model.Metadata{ - ID: 1, - ProjectID: 2, + ProjectID: 1, Priority: 1, - Template: "latestPushedK", - Action: "immuablity", + Action: "immutable", + Template: "immutable_template", TagSelectors: []*model.Selector{ { Kind: "doublestar", Decoration: "matches", - Pattern: "release-[\\d\\.]+", + Pattern: "release-**", }, }, ScopeSelectors: map[string][]*model.Selector{ "repository": { { Kind: "doublestar", - Decoration: "matches", + Decoration: "repoMatches", Pattern: "redis", }, }, }, } - rule2 := &model.Metadata{ - ID: 1, - ProjectID: 2, + ProjectID: 1, Priority: 1, - Template: "latestPushedK", + Template: "immutable_template", Action: "immuablity", TagSelectors: []*model.Selector{ { @@ -74,7 +70,7 @@ func (s *MatchTestSuite) TestImmuMatch() { "repository": { { Kind: "doublestar", - Decoration: "matches", + Decoration: "repoMatches", Pattern: "mysql", }, }, @@ -83,69 +79,52 @@ func (s *MatchTestSuite) TestImmuMatch() { id, err := s.ctr.CreateImmutableRule(rule) s.ruleID = id - s.require.NotNil(err) + s.require.Nil(err) id, err = s.ctr.CreateImmutableRule(rule2) s.ruleID2 = id - s.require.NotNil(err) + s.require.Nil(err) - match := NewRuleMatcher(2) + match := NewRuleMatcher(1) c1 := art.Candidate{ - NamespaceID: 2, - Namespace: "immutable", - Repository: "redis", - Tag: "release-1.10", - Kind: art.Image, - PushedTime: time.Now().Unix() - 3600, - PulledTime: time.Now().Unix(), - CreationTime: time.Now().Unix() - 7200, - Labels: []string{"label1", "label4", "label5"}, + NamespaceID: 1, + Namespace: "library", + Repository: "redis", + Tag: "release-1.10", } isMatch, err := match.Match(c1) s.require.Equal(isMatch, true) s.require.Nil(err) c2 := art.Candidate{ - NamespaceID: 2, - Namespace: "immutable", - Repository: "redis", - Tag: "1.10", - Kind: art.Image, - PushedTime: time.Now().Unix() - 3600, - PulledTime: time.Now().Unix(), - CreationTime: time.Now().Unix() - 7200, - Labels: []string{"label1", "label4", "label5"}, + NamespaceID: 1, + Namespace: "library", + Repository: "redis", + Tag: "1.10", + Kind: art.Image, } isMatch, err = match.Match(c2) s.require.Equal(isMatch, false) s.require.Nil(err) c3 := art.Candidate{ - NamespaceID: 2, - Namespace: "immutable", - Repository: "mysql", - Tag: "9.4.8", - Kind: art.Image, - PushedTime: time.Now().Unix() - 3600, - PulledTime: time.Now().Unix(), - CreationTime: time.Now().Unix() - 7200, - Labels: []string{"label1"}, + NamespaceID: 1, + Namespace: "immutable", + Repository: "mysql", + Tag: "9.4.8", + Kind: art.Image, } isMatch, err = match.Match(c3) s.require.Equal(isMatch, true) s.require.Nil(err) c4 := art.Candidate{ - NamespaceID: 2, - Namespace: "immutable", - Repository: "hello", - Tag: "world", - Kind: art.Image, - PushedTime: time.Now().Unix() - 3600, - PulledTime: time.Now().Unix(), - CreationTime: time.Now().Unix() - 7200, - Labels: []string{"label1"}, + NamespaceID: 1, + Namespace: "immutable", + Repository: "hello", + Tag: "world", + Kind: art.Image, } isMatch, err = match.Match(c4) s.require.Equal(isMatch, false) @@ -160,3 +139,15 @@ func (s *MatchTestSuite) TearDownSuite() { err = s.ctr.DeleteImmutableRule(s.ruleID2) require.NoError(s.T(), err, "delete immutable") } + +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(MatchTestSuite)) +}