Gate local repo writes behind OIDC tokens
Add ARTIFACTAPI_AUTH_UPLOADS to require a verified token for local uploads, deletes, evictions, /api/v1 publishes and /v2 push. Accept the token as Bearer, Token, Basic password or bare header, and serve a Docker registry token endpoint so pulls stay anonymous.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -78,3 +79,98 @@ func TestServerOIDCWriteAuth(t *testing.T) {
|
||||
expect(204, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", good)
|
||||
expect(204, "DELETE", "/api/v2/remotes/srv-oidc", "", good)
|
||||
}
|
||||
|
||||
func TestServerAuthUploads(t *testing.T) {
|
||||
requireStack(t)
|
||||
iss := testsupport.NewOIDCIssuer()
|
||||
defer iss.Close()
|
||||
|
||||
cfg := *testSrv.cfg
|
||||
cfg.OIDCIssuer = iss.URL
|
||||
cfg.OIDCAudiences = []string{"artifactapi"}
|
||||
cfg.AuthUploads = true
|
||||
s := *testSrv
|
||||
s.cfg = &cfg
|
||||
ts := httptest.NewServer(s.routes())
|
||||
defer ts.Close()
|
||||
|
||||
good := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||
do := func(method, path, body, authz string) int {
|
||||
t.Helper()
|
||||
rq, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||
rq.Header.Set("Content-Type", "application/json")
|
||||
if authz != "" {
|
||||
rq.Header.Set("Authorization", authz)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(rq)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
bearer := "Bearer " + good
|
||||
for _, typ := range []string{"generic", "docker", "npm", "gems"} {
|
||||
body := `{"name":"srv-up-` + typ + `","package_type":"` + typ + `","repo_type":"local"}`
|
||||
if got := do("POST", "/api/v2/remotes", body, bearer); got != 201 {
|
||||
t.Fatalf("create %s: status %d", typ, got)
|
||||
}
|
||||
}
|
||||
|
||||
writes := []struct{ method, path, body string }{
|
||||
{"PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload"},
|
||||
{"DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", ""},
|
||||
{"DELETE", "/api/v2/remotes/srv-up-generic/objects/a.bin", ""},
|
||||
{"DELETE", "/api/v2/locals/srv-up-generic/objects/a.bin", ""},
|
||||
{"PUT", "/api/v1/local/srv-up-npm/pkg", "{}"},
|
||||
{"POST", "/api/v1/local/srv-up-gems/api/v1/gems", "x"},
|
||||
{"DELETE", "/api/v1/local/srv-up-gems/api/v1/gems/yank", ""},
|
||||
{"POST", "/v2/srv-up-docker/img/blobs/uploads/", ""},
|
||||
{"PATCH", "/v2/srv-up-docker/img/blobs/uploads/x", ""},
|
||||
{"PUT", "/v2/srv-up-docker/img/manifests/t", "{}"},
|
||||
{"DELETE", "/v2/srv-up-docker/img/manifests/t", ""},
|
||||
}
|
||||
for _, w := range writes {
|
||||
if got := do(w.method, w.path, w.body, ""); got != 401 {
|
||||
t.Errorf("anonymous %s %s: status %d, want 401", w.method, w.path, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/a.bin", "payload", bearer); got != 201 {
|
||||
t.Errorf("bearer upload: status %d", got)
|
||||
}
|
||||
if got := do("PUT", "/api/v2/remotes/srv-up-generic/files/b.bin", "payload", basic(good)); got != 201 {
|
||||
t.Errorf("basic upload: status %d", got)
|
||||
}
|
||||
if got := do("POST", "/v2/srv-up-docker/img/blobs/uploads/", "", basic(good)); got != 202 {
|
||||
t.Errorf("basic docker upload: status %d", got)
|
||||
}
|
||||
expectOpen := func(method, path string) {
|
||||
t.Helper()
|
||||
if got := do(method, path, "", ""); got >= 400 {
|
||||
t.Errorf("anonymous %s %s: status %d", method, path, got)
|
||||
}
|
||||
}
|
||||
expectOpen("GET", "/api/v2/remotes/srv-up-generic/files/a.bin")
|
||||
expectOpen("GET", "/api/v2/remotes/srv-up-generic/objects")
|
||||
expectOpen("GET", "/api/v1/local/srv-up-generic/a.bin")
|
||||
expectOpen("GET", "/v2/token")
|
||||
if got := do("GET", "/v2/", "", ""); got != 401 {
|
||||
t.Errorf("anonymous ping: status %d, want 401 challenge", got)
|
||||
}
|
||||
if got := do("POST", "/api/v2/probe", `{}`, ""); got == 401 {
|
||||
t.Error("probe gated")
|
||||
}
|
||||
|
||||
if got := do("DELETE", "/api/v2/remotes/srv-up-generic/files/a.bin", "", bearer); got >= 400 {
|
||||
t.Errorf("bearer delete: status %d", got)
|
||||
}
|
||||
for _, typ := range []string{"generic", "docker", "npm", "gems"} {
|
||||
do("DELETE", "/api/v2/remotes/srv-up-"+typ, "", bearer)
|
||||
}
|
||||
}
|
||||
|
||||
func basic(tok string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte("ci:"+tok))
|
||||
}
|
||||
|
||||
@@ -169,8 +169,17 @@ func (s *Server) routes() chi.Router {
|
||||
s.virtEngine.NPMLocal = proxyHandler.LocalNPMPackument
|
||||
s.virtEngine.GemsLocal = proxyHandler.LocalGemsIndex
|
||||
s.virtEngine.AnsibleLocal = proxyHandler.LocalAnsible
|
||||
r.Mount("/api/v1", proxyHandler.Routes())
|
||||
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
||||
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
|
||||
uploadAuth := passthrough
|
||||
dockerV2 := http.Handler(proxyHandler.DockerV2Routes())
|
||||
if s.cfg.AuthUploads {
|
||||
uploadAuth = writeAuth.RequireWrites
|
||||
dockerV2 = writeAuth.RequireRegistryWrites(dockerV2, func(r *http.Request) string {
|
||||
return v1.Scheme(r) + "://" + r.Host + "/v2/token"
|
||||
})
|
||||
}
|
||||
r.Mount("/api/v1", uploadAuth(proxyHandler.Routes()))
|
||||
r.Mount("/v2", dockerV2)
|
||||
|
||||
remotesHandler := v2.NewRemotesHandler(s.db, s.cache, map[models.PackageType]v2.Primer{
|
||||
models.PackageGitHubRPM: s.syncer,
|
||||
@@ -182,7 +191,6 @@ func (s *Server) routes() chi.Router {
|
||||
statsHandler := v2.NewStatsHandler(s.db)
|
||||
eventsHandler := v2.NewEventsHandler()
|
||||
probeHandler := v2.NewProbeHandler(s.engine, s.db)
|
||||
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
|
||||
|
||||
r.Route("/api/v2", func(r chi.Router) {
|
||||
r.Mount("/remotes", writeAuth.RequireWrites(remotesHandler.Routes()))
|
||||
@@ -192,19 +200,19 @@ func (s *Server) routes() chi.Router {
|
||||
r.Mount("/events", eventsHandler.Routes())
|
||||
r.Mount("/probe", probeHandler.Routes())
|
||||
|
||||
r.Route("/remotes/{name}/objects", func(r chi.Router) {
|
||||
r.With(uploadAuth).Route("/remotes/{name}/objects", func(r chi.Router) {
|
||||
objHandler := v2.NewObjectsHandler(s.db)
|
||||
r.Get("/", objHandler.Routes(s.engine).ServeHTTP)
|
||||
r.Delete("/*", objHandler.Routes(s.engine).ServeHTTP)
|
||||
})
|
||||
|
||||
r.Route("/locals/{name}/objects", func(r chi.Router) {
|
||||
r.With(uploadAuth).Route("/locals/{name}/objects", func(r chi.Router) {
|
||||
objHandler := v2.NewObjectsHandler(s.db)
|
||||
r.Get("/", objHandler.LocalRoutes().ServeHTTP)
|
||||
r.Delete("/*", objHandler.LocalRoutes().ServeHTTP)
|
||||
})
|
||||
|
||||
r.Route("/remotes/{name}/files", func(r chi.Router) {
|
||||
r.With(uploadAuth).Route("/remotes/{name}/files", func(r chi.Router) {
|
||||
r.Put("/*", s.localHandler.Routes().ServeHTTP)
|
||||
r.Get("/*", s.localHandler.Routes().ServeHTTP)
|
||||
r.Delete("/*", s.localHandler.Routes().ServeHTTP)
|
||||
@@ -214,6 +222,8 @@ func (s *Server) routes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
func passthrough(h http.Handler) http.Handler { return h }
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
Reference in New Issue
Block a user