diff --git a/const.go b/const.go index 5f920cc82b..be30192080 100644 --- a/const.go +++ b/const.go @@ -71,6 +71,9 @@ const ( // and PrivNoExecPerms for file. PrivExecPerms = 0700 PrivNoExecPerms = 0600 + + // DefaultPageSize is the default number of records to return from the changefeed + DefaultPageSize = 100 ) // enum to use for setting and retrieving values from contexts diff --git a/migrations/server/mysql/0005_changefeed.up.sql b/migrations/server/mysql/0005_changefeed.up.sql new file mode 100644 index 0000000000..963400301e --- /dev/null +++ b/migrations/server/mysql/0005_changefeed.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE `changefeed` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `gun` varchar(255) NOT NULL, + `version` int(11) NOT NULL, + `sha256` CHAR(64) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/migrations/server/postgresql/0002_changefeed.up.sql b/migrations/server/postgresql/0002_changefeed.up.sql new file mode 100644 index 0000000000..095565ec19 --- /dev/null +++ b/migrations/server/postgresql/0002_changefeed.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE "changefeed" ( + "id" serial PRIMARY KEY, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP, + "gun" varchar(255) NOT NULL, + "version" integer NOT NULL, + "sha256" CHAR(64) DEFAULT NULL, +); \ No newline at end of file diff --git a/server/errors/errors.go b/server/errors/errors.go index 3ccb7d3f36..8195270b54 100644 --- a/server/errors/errors.go +++ b/server/errors/errors.go @@ -89,7 +89,13 @@ var ( ErrInvalidGUN = errcode.Register(errGroup, errcode.ErrorDescriptor{ Value: "INVALID_GUN", Message: "The server does not support actions on images of this name.", - Description: "The server does not support actions on images of this name", + Description: "The server does not support actions on images of this name.", + HTTPStatusCode: http.StatusBadRequest, + }) + ErrInvalidParams = errcode.Register(errGroup, errcode.ErrorDescriptor{ + Value: "INVALID_PARAMETERS", + Message: "The parameters provided are not valid.", + Description: "The parameters provided are not valid.", HTTPStatusCode: http.StatusBadRequest, }) ErrUnknown = errcode.ErrorCodeUnknown diff --git a/server/handlers/changefeed.go b/server/handlers/changefeed.go new file mode 100644 index 0000000000..ddd50f9af0 --- /dev/null +++ b/server/handlers/changefeed.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + ctxu "github.com/docker/distribution/context" + "golang.org/x/net/context" + + "github.com/docker/notary" + "github.com/docker/notary/server/errors" + "github.com/docker/notary/server/storage" +) + +type changefeedResponse struct { + NumberOfRecords int `json:"count"` + Records []storage.Change `json:"records"` +} + +// Changefeed returns a list of changes according to the provided filters +func Changefeed(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + logger := ctxu.GetLogger(ctx) + s := ctx.Value(notary.CtxKeyMetaStore) + store, ok := s.(storage.MetaStore) + if !ok { + logger.Error("500 GET unable to retrieve storage") + return errors.ErrNoStorage.WithDetail(nil) + } + + qs := r.URL.Query() + imageName := qs.Get("filter") + if imageName == "" { + // no image name means global feed + imageName = "*" + } + pageSizeStr := qs.Get("pageSize") + pageSize, err := strconv.ParseInt(pageSizeStr, 10, 32) + if err != nil { + logger.Errorf("400 GET invalid pageSize: %s", pageSizeStr) + return errors.ErrInvalidParams.WithDetail("invalid pageSize parameter, must be an integer >= 0") + } + if pageSize == 0 { + pageSize = notary.DefaultPageSize + } + + changeID := qs.Get("changeID") + reversedStr := qs.Get("reversed") + reversed := reversedStr == "1" || strings.ToLower(reversedStr) == "true" + + changes, err := store.GetChanges(changeID, int(pageSize), imageName, reversed) + if err != nil { + logger.Errorf("500 GET could not retrieve records: %s", err.Error()) + return errors.ErrUnknown.WithDetail(err) + } + + // if reversed, we need to flip the list order so oldest is first + if reversed { + for i, j := 0, len(changes)-1; i < j; i, j = i+1, j-1 { + changes[i], changes[j] = changes[j], changes[i] + } + } + + out, err := json.Marshal(&changefeedResponse{ + NumberOfRecords: len(changes), + Records: changes, + }) + if err != nil { + logger.Error("500 GET could not json.Marshal changefeedResponse") + return errors.ErrUnknown.WithDetail(err) + } + w.Write(out) + return nil +} diff --git a/server/handlers/default_test.go b/server/handlers/default_test.go index 467e34aa78..daecada39f 100644 --- a/server/handlers/default_test.go +++ b/server/handlers/default_test.go @@ -51,7 +51,7 @@ func getContext(h handlerState) context.Context { func TestMainHandlerGet(t *testing.T) { hand := utils.RootHandlerFactory(context.Background(), nil, &signed.Ed25519{}) - handler := hand(MainHandler) + handler := hand(MainHandler, utils.NoImageName) ts := httptest.NewServer(handler) defer ts.Close() @@ -63,7 +63,7 @@ func TestMainHandlerGet(t *testing.T) { func TestMainHandlerNotGet(t *testing.T) { hand := utils.RootHandlerFactory(context.Background(), nil, &signed.Ed25519{}) - handler := hand(MainHandler) + handler := hand(MainHandler, utils.NoImageName) ts := httptest.NewServer(handler) defer ts.Close() diff --git a/server/server.go b/server/server.go index a62cd3df71..749cc1ff0e 100644 --- a/server/server.go +++ b/server/server.go @@ -119,6 +119,11 @@ type _serverEndpoint struct { IncludeCacheHeaders bool CacheControlConfig utils.CacheControlConfig PermissionsRequired []string + // ImageNameAt defaults to 0, which will cause the image name to be looked + // up in the URL segment. Therefore only need to set it when in some other + // place. + ImageNameAt utils.ImageNameLocation + SkipFilterPrefixes bool } // RootHandler returns the handler that routes all the paths from / for the @@ -130,11 +135,13 @@ func RootHandler(ctx context.Context, ac auth.AccessController, trust signed.Cry createHandler := func(opts _serverEndpoint) http.Handler { var wrapped http.Handler - wrapped = authWrapper(opts.ServerHandler, opts.PermissionsRequired...) + wrapped = authWrapper(opts.ServerHandler, opts.ImageNameAt, opts.PermissionsRequired...) if opts.IncludeCacheHeaders { wrapped = utils.WrapWithCacheHandler(opts.CacheControlConfig, wrapped) } - wrapped = filterImagePrefixes(repoPrefixes, opts.ErrorIfGUNInvalid, wrapped) + if !opts.SkipFilterPrefixes { + wrapped = filterImagePrefixes(repoPrefixes, opts.ErrorIfGUNInvalid, wrapped) + } return prometheus.InstrumentHandlerWithOpts(prometheusOpts(opts.OperationName), wrapped) } @@ -142,7 +149,7 @@ func RootHandler(ctx context.Context, ac auth.AccessController, trust signed.Cry notFoundError := errors.ErrMetadataNotFound.WithDetail(nil) r := mux.NewRouter() - r.Methods("GET").Path("/v2/").Handler(authWrapper(handlers.MainHandler)) + r.Methods("GET").Path("/v2/").Handler(authWrapper(handlers.MainHandler, utils.NoImageName)) r.Methods("POST").Path("/v2/{imageName:.*}/_trust/tuf/").Handler(createHandler(_serverEndpoint{ OperationName: "UpdateTUF", @@ -186,11 +193,18 @@ func RootHandler(ctx context.Context, ac auth.AccessController, trust signed.Cry ServerHandler: handlers.DeleteHandler, PermissionsRequired: []string{"*"}, })) + r.Methods("GET").Path("/v2/changefeed/_trust").Handler(createHandler(_serverEndpoint{ + OperationName: "Changefeed", + ServerHandler: handlers.Changefeed, + PermissionsRequired: []string{"*"}, + ImageNameAt: utils.ImageInQueryString, + SkipFilterPrefixes: true, + })) r.Methods("GET").Path("/_notary_server/health").HandlerFunc(health.StatusHandler) r.Methods("GET").Path("/metrics").Handler(prometheus.Handler()) r.Methods("GET", "POST", "PUT", "HEAD", "DELETE").Path("/{other:.*}").Handler( - authWrapper(handlers.NotFoundHandler)) + authWrapper(handlers.NotFoundHandler, utils.NoImageName)) return r } diff --git a/server/storage/errors.go b/server/storage/errors.go index abee09a9f1..318594099a 100644 --- a/server/storage/errors.go +++ b/server/storage/errors.go @@ -40,3 +40,13 @@ type ErrNoKey struct { func (err ErrNoKey) Error() string { return fmt.Sprintf("Error, no timestamp key found for %s", err.gun) } + +// ErrBadChangeID indicates the change ID provided by the user is not +// valid for some reason, i.e. it is out of bounds +type ErrBadChangeID struct { + id string +} + +func (err ErrBadChangeID) Error() string { + return fmt.Sprintf("Error, the change ID \"%s\" is not valid", err.id) +} diff --git a/server/storage/interface.go b/server/storage/interface.go index 0c11a93eec..fda0965667 100644 --- a/server/storage/interface.go +++ b/server/storage/interface.go @@ -39,4 +39,28 @@ type MetaStore interface { // Delete removes all metadata for a given GUN. It does not return an // error if no metadata exists for the given GUN. Delete(gun string) error + + // GetChanges returns an ordered slice of changes. It starts from + // the change matching changeID, but excludes this change from the results + // on the assumption that if a user provides an ID, they've seen that change. + // If changeID is 0, it starts from the + // beginning, and if changeID is -1, it starts from the most recent + // change. The number of results returned is limited by pageSize. + // Reversed indicates we are fetching pages going backwards in time, the + // default being to fetch pageSize from changeID going forwards in time. + GetChanges(changeID string, pageSize int, filterName string, reversed bool) ([]Change, error) +} + +// Change implements the minimal interface to get the change data. +type Change interface { + // ChangeID returns the unique ID for this update + ChangeID() string + // GUN returns the GUN for this update + GUN() string + // Version returns the timestamp version for the published update + Version() int + // Checksum returns the timestamp.json checksum for the published update + Checksum() string + // RecordedAt returns the time at which the update was recorded + RecordedAt() time.Time } diff --git a/server/storage/memory.go b/server/storage/memory.go index c750368435..a51b1491ea 100644 --- a/server/storage/memory.go +++ b/server/storage/memory.go @@ -8,6 +8,10 @@ import ( "strings" "sync" "time" + + "strconv" + + "github.com/docker/notary/tuf/data" ) type key struct { @@ -31,6 +35,20 @@ func (k verList) Less(i, j int) bool { return k[i].version < k[j].version } +type change struct { + id int + gun string + ver int + checksum string + recordedAt time.Time +} + +func (c change) ChangeID() string { return fmt.Sprintf("%d", c.id) } +func (c change) GUN() string { return c.gun } +func (c change) Version() int { return c.ver } +func (c change) Checksum() string { return c.checksum } +func (c change) RecordedAt() time.Time { return c.recordedAt } + // MemStorage is really just designed for dev and testing. It is very // inefficient in many scenarios type MemStorage struct { @@ -38,6 +56,7 @@ type MemStorage struct { tufMeta map[string]verList keys map[string]map[string]*key checksums map[string]map[string]ver + changes []Change } // NewMemStorage instantiates a memStorage instance @@ -71,9 +90,25 @@ func (st *MemStorage) UpdateCurrent(gun string, update MetaUpdate) error { st.checksums[gun] = make(map[string]ver) } st.checksums[gun][checksum] = version + if update.Role == data.CanonicalTimestampRole { + st.writeChange(gun, update.Version, checksum) + } return nil } +// writeChange must only be called by a function already holding a lock on +// the MemStorage. Behaviour is undefined otherwise +func (st *MemStorage) writeChange(gun string, version int, checksum string) { + c := change{ + id: len(st.changes), + gun: gun, + ver: version, + checksum: checksum, + recordedAt: time.Now(), + } + st.changes = append(st.changes, c) +} + // UpdateMany updates multiple TUF records func (st *MemStorage) UpdateMany(gun string, updates []MetaUpdate) error { st.lock.Lock() @@ -118,6 +153,9 @@ func (st *MemStorage) UpdateMany(gun string, updates []MetaUpdate) error { st.checksums[gun] = make(map[string]ver) } st.checksums[gun][checksum] = version + if u.Role == data.CanonicalTimestampRole { + st.writeChange(gun, u.Version, checksum) + } } return nil } @@ -158,6 +196,25 @@ func (st *MemStorage) Delete(gun string) error { return nil } +// GetChanges returns a []Change starting from but excluding the record +// identified by changeID. In the context of the memory store, changeID +// is simply an index into st.changes. The ID of a change, and its index +// are equal, therefore, we want to return results starting at index +// changeID+1 to match the exclusivity of the interface definition. +func (st *MemStorage) GetChanges(changeID string, pageSize int, filterName string, reversed bool) ([]Change, error) { + id, err := strconv.ParseInt(changeID, 10, 32) + size := len(st.changes) + if err != nil || size <= int(id) { + return nil, ErrBadChangeID{id: changeID} + } + start := int(id) + 1 + end := start + pageSize + if end >= size { + return st.changes[start:], nil + } + return st.changes[start:end], nil +} + func entryKey(gun, role string) string { return fmt.Sprintf("%s.%s", gun, role) } diff --git a/server/storage/mysql_test.go b/server/storage/mysql_test.go index 16ed27eb33..2c6364da01 100644 --- a/server/storage/mysql_test.go +++ b/server/storage/mysql_test.go @@ -43,6 +43,7 @@ func init() { // drop all tables, if they exist gormDB.DropTable(&TUFFile{}) + gormDB.DropTable(&Changefeed{}) } cleanup1() dbStore := SetupSQLDB(t, "mysql", dburl) diff --git a/server/storage/postgresql_test.go b/server/storage/postgresql_test.go index a2a6b94920..d5e183f865 100644 --- a/server/storage/postgresql_test.go +++ b/server/storage/postgresql_test.go @@ -44,6 +44,7 @@ func init() { // drop all tables, if they exist gormDB.DropTable(&TUFFile{}) + gormDB.DropTable(&Changefeed{}) } cleanup1() dbStore := SetupSQLDB(t, notary.PostgresBackend, dburl) diff --git a/server/storage/rethinkdb.go b/server/storage/rethinkdb.go index 1a27176d4c..7b77e286dc 100644 --- a/server/storage/rethinkdb.go +++ b/server/storage/rethinkdb.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "sort" "time" @@ -274,3 +275,8 @@ func (rdb RethinkDB) CheckHealth() error { defer res.Close() return nil } + +// GetChanges is not implemented for RethinkDB +func (rdb RethinkDB) GetChanges(changeID string, pageSize int, filterName string, reversed bool) ([]Change, error) { + return nil, errors.New("Not Implemented") +} diff --git a/server/storage/sql_models.go b/server/storage/sql_models.go index 21ef72c4c6..e95ef512e7 100644 --- a/server/storage/sql_models.go +++ b/server/storage/sql_models.go @@ -1,6 +1,11 @@ package storage -import "github.com/jinzhu/gorm" +import ( + "fmt" + "time" + + "github.com/jinzhu/gorm" +) // TUFFile represents a TUF file in the database type TUFFile struct { @@ -17,6 +22,30 @@ func (g TUFFile) TableName() string { return "tuf_files" } +// Changefeed defines the Change object for SQL databases +type Changefeed struct { + ID uint `gorm:"primary_key" sql:"not null"` + CreatedAt time.Time + Gun string `sql:"type:varchar(255);not null"` + Ver int `gorm:"column:version" sql:"not null"` + Sha256 string `sql:"type:varchar(64);"` +} + +// ChangeID returns the unique ID for this update +func (c Changefeed) ChangeID() string { return fmt.Sprintf("%d", c.ID) } + +// GUN returns the GUN for this update +func (c Changefeed) GUN() string { return c.Gun } + +// Version returns the timestamp version for the published update +func (c Changefeed) Version() int { return c.Ver } + +// Checksum returns the timestamp.json checksum for the published update +func (c Changefeed) Checksum() string { return c.Sha256 } + +// RecordedAt returns the time at which the update was recorded +func (c Changefeed) RecordedAt() time.Time { return c.CreatedAt } + // CreateTUFTable creates the DB table for TUFFile func CreateTUFTable(db gorm.DB) error { // TODO: gorm @@ -26,8 +55,11 @@ func CreateTUFTable(db gorm.DB) error { } query = db.Model(&TUFFile{}).AddUniqueIndex( "idx_gun", "gun", "role", "version") - if query.Error != nil { - return query.Error - } - return nil + return query.Error +} + +// CreateChangefeedTable creates the DB table for Changefeed +func CreateChangefeedTable(db gorm.DB) error { + query := db.Set("gorm:table_options", "ENGINE=InnoDB DEFAULT CHARSET=utf8").CreateTable(&Changefeed{}) + return query.Error } diff --git a/server/storage/sqldb.go b/server/storage/sqldb.go index e56bb4e74c..8a48a2a8ce 100644 --- a/server/storage/sqldb.go +++ b/server/storage/sqldb.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Sirupsen/logrus" + "github.com/docker/notary/tuf/data" "github.com/go-sql-driver/mysql" "github.com/jinzhu/gorm" ) @@ -54,24 +55,43 @@ func (db *SQLStorage) UpdateCurrent(gun string, update MetaUpdate) error { if !exists.RecordNotFound() { return ErrOldVersion{} } + + // only take out the transaction once we're about to start writing + tx, rb, err := db.getTransaction() + checksum := sha256.Sum256(update.Data) - return translateOldVersionError(db.Create(&TUFFile{ + hexChecksum := hex.EncodeToString(checksum[:]) + + // write new TUFFile entry + if err = translateOldVersionError(tx.Create(&TUFFile{ Gun: gun, Role: update.Role, Version: update.Version, - Sha256: hex.EncodeToString(checksum[:]), + Sha256: hexChecksum, Data: update.Data, - }).Error) + }).Error); err != nil { + return rb(err) + } + + // If we're publishing a timestamp, update the changefeed as this is + // technically an new version of the TUF repo + if update.Role == data.CanonicalTimestampRole { + if err := db.writeChangefeed(tx, gun, update.Version, hexChecksum); err != nil { + return rb(err) + } + } + return tx.Commit().Error } -// UpdateMany atomically updates many TUF records in a single transaction -func (db *SQLStorage) UpdateMany(gun string, updates []MetaUpdate) error { +type rollback func(error) error + +func (db *SQLStorage) getTransaction() (*gorm.DB, rollback, error) { tx := db.Begin() if tx.Error != nil { - return tx.Error + return nil, nil, tx.Error } - rollback := func(err error) error { + rb := func(err error) error { if rxErr := tx.Rollback().Error; rxErr != nil { logrus.Error("Failed on Tx rollback with error: ", rxErr.Error()) return rxErr @@ -79,6 +99,15 @@ func (db *SQLStorage) UpdateMany(gun string, updates []MetaUpdate) error { return err } + return tx, rb, nil +} + +// UpdateMany atomically updates many TUF records in a single transaction +func (db *SQLStorage) UpdateMany(gun string, updates []MetaUpdate) error { + tx, rb, err := db.getTransaction() + if err != nil { + return err + } var ( query *gorm.DB added = make(map[uint]bool) @@ -92,7 +121,7 @@ func (db *SQLStorage) UpdateMany(gun string, updates []MetaUpdate) error { gun, update.Role, update.Version).First(&TUFFile{}) if !query.RecordNotFound() { - return rollback(ErrOldVersion{}) + return rb(ErrOldVersion{}) } var row TUFFile @@ -105,18 +134,32 @@ func (db *SQLStorage) UpdateMany(gun string, updates []MetaUpdate) error { }).Attrs("data", update.Data).Attrs("sha256", hexChecksum).FirstOrCreate(&row) if query.Error != nil { - return rollback(translateOldVersionError(query.Error)) + return rb(translateOldVersionError(query.Error)) } // it's previously been added, which means it's a duplicate entry // in the same transaction if _, ok := added[row.ID]; ok { - return rollback(ErrOldVersion{}) + return rb(ErrOldVersion{}) + } + if update.Role == data.CanonicalTimestampRole { + if err := db.writeChangefeed(tx, gun, update.Version, hexChecksum); err != nil { + return rb(err) + } } added[row.ID] = true } return tx.Commit().Error } +func (db *SQLStorage) writeChangefeed(tx *gorm.DB, gun string, version int, checksum string) error { + cf := &Changefeed{ + Gun: gun, + Ver: version, + Sha256: checksum, + } + return tx.Create(cf).Error +} + // GetCurrent gets a specific TUF record func (db *SQLStorage) GetCurrent(gun, tufRole string) (*time.Time, []byte, error) { var row TUFFile @@ -171,3 +214,25 @@ func (db *SQLStorage) CheckHealth() error { } return nil } + +// GetChanges returns up to pageSize changes starting from changeID. +func (db *SQLStorage) GetChanges(changeID string, pageSize int, filterName string, reversed bool) ([]Change, error) { + var ( + changes []Change + query = &db.DB + ) + if filterName != "" { + query = query.Where("gun = ?", filterName) + } + if reversed { + query = query.Where("id < ?", changeID).Order("id desc") + } else { + query = query.Where("id > ?", changeID).Order("id asc") + } + + res := query.Limit(pageSize).Find(&changes) + if res.Error != nil { + return nil, res.Error + } + return changes, nil +} diff --git a/server/storage/sqldb_test.go b/server/storage/sqldb_test.go index c3ccf843a3..1fba118ba4 100644 --- a/server/storage/sqldb_test.go +++ b/server/storage/sqldb_test.go @@ -19,8 +19,8 @@ func SetupSQLDB(t *testing.T, dbtype, dburl string) *SQLStorage { require.NoError(t, err) // Create the DB tables - err = CreateTUFTable(dbStore.DB) - require.NoError(t, err) + require.NoError(t, CreateTUFTable(dbStore.DB)) + require.NoError(t, CreateChangefeedTable(dbStore.DB)) // verify that the tables are empty var count int diff --git a/utils/http.go b/utils/http.go index 8c7c438d36..7d70afe77a 100644 --- a/utils/http.go +++ b/utils/http.go @@ -1,6 +1,7 @@ package utils import ( + "errors" "fmt" "net/http" "time" @@ -28,29 +29,68 @@ type rootHandler struct { context context.Context trust signed.CryptoService //cachePool redis.Pool + imageNameAt ImageNameLocation +} + +// ImageNameLocation is used to define where the auth code should read the image name +// from. For most repo operations, it will be ImageInURL, i.e. it's the segments between +// `/v2/` and `/_trust`. For the changefeed however, an image name is optional, and if +// present will be found in the query string. +type ImageNameLocation int + +// Constants for different image locations +const ( + ImageInURL = iota + ImageInQueryString + NoImageName +) + +func parseImageName(loc ImageNameLocation, r *http.Request) (string, error) { + switch loc { + case ImageInURL: + vars := mux.Vars(r) + return vars["imageName"], nil + case ImageInQueryString: + qs := r.URL.Query() + imageName := qs.Get("filter") + if imageName == "" { + // no image name means global feed + imageName = "*" + } + return imageName, nil + case NoImageName: + // legacy behaviour resulted in an empty string when the URL route + // didn't define an image name + return "", nil + } + return "", errors.New("Unrecognized location for image name") } // RootHandlerFactory creates a new rootHandler factory using the given // Context creator and authorizer. The returned factory allows creating // new rootHandlers from the alternate http handler contextHandler and // a scope. -func RootHandlerFactory(ctx context.Context, auth auth.AccessController, trust signed.CryptoService) func(ContextHandler, ...string) *rootHandler { - return func(handler ContextHandler, actions ...string) *rootHandler { +func RootHandlerFactory(ctx context.Context, auth auth.AccessController, trust signed.CryptoService) func(ContextHandler, ImageNameLocation, ...string) *rootHandler { + return func(handler ContextHandler, imageNameAt ImageNameLocation, actions ...string) *rootHandler { return &rootHandler{ - handler: handler, - auth: auth, - actions: actions, - context: ctx, - trust: trust, + handler: handler, + auth: auth, + actions: actions, + context: ctx, + trust: trust, + imageNameAt: imageNameAt, } } } // ServeHTTP serves an HTTP request and implements the http.Handler interface. func (root *rootHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - ctx := ctxu.WithRequest(root.context, r) - log := ctxu.GetRequestLogger(ctx) + var ( + err error + vars = mux.Vars(r) + ctx = ctxu.WithRequest(root.context, r) + log = ctxu.GetRequestLogger(ctx) + ) ctx, w = ctxu.WithResponseWriter(ctx, w) ctx = ctxu.WithLogger(ctx, log) ctx = context.WithValue(ctx, notary.CtxKeyRepo, vars["imageName"]) @@ -61,23 +101,11 @@ func (root *rootHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }() if root.auth != nil { - access := buildAccessRecords(vars["imageName"], root.actions...) - var authCtx context.Context - var err error - if authCtx, err = root.auth.Authorized(ctx, access...); err != nil { - if challenge, ok := err.(auth.Challenge); ok { - // Let the challenge write the response. - challenge.SetHeaders(w) - - if err := errcode.ServeJSON(w, errcode.ErrorCodeUnauthorized.WithDetail(access)); err != nil { - log.Errorf("failed to serve challenge response: %s", err.Error()) - } - return - } - errcode.ServeJSON(w, errcode.ErrorCodeUnauthorized) + if ctx, err = root.doAuth(ctx, vars["imagName"], w); err != nil { + // errors have already been logged/output to w inside doAuth + // just return return } - ctx = authCtx } if err := root.handler(ctx, w, r); err != nil { if httpErr, ok := err.(errcode.Error); ok { @@ -98,6 +126,34 @@ func (root *rootHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (root *rootHandler) doAuth(ctx context.Context, imageName string, w http.ResponseWriter) (context.Context, error) { + var access []auth.Access + if imageName == "*" { + access = buildCatalogRecord(root.actions...) + } else { + access = buildAccessRecords(imageName, root.actions...) + } + + log := ctxu.GetRequestLogger(ctx) + var authCtx context.Context + var err error + if authCtx, err = root.auth.Authorized(ctx, access...); err != nil { + if challenge, ok := err.(auth.Challenge); ok { + // Let the challenge write the response. + challenge.SetHeaders(w) + + if err := errcode.ServeJSON(w, errcode.ErrorCodeUnauthorized.WithDetail(access)); err != nil { + log.Errorf("failed to serve challenge response: %s", err.Error()) + return nil, err + } + return nil, err + } + errcode.ServeJSON(w, errcode.ErrorCodeUnauthorized) + return nil, err + } + return authCtx, nil +} + func buildAccessRecords(repo string, actions ...string) []auth.Access { requiredAccess := make([]auth.Access, 0, len(actions)) for _, action := range actions { @@ -112,6 +168,20 @@ func buildAccessRecords(repo string, actions ...string) []auth.Access { return requiredAccess } +func buildCatalogRecord(actions ...string) []auth.Access { + requiredAccess := make([]auth.Access, 0, len(actions)) + for _, action := range actions { + requiredAccess = append(requiredAccess, auth.Access{ + Resource: auth.Resource{ + Type: "registry", + Name: "catalog", + }, + Action: action, + }) + } + return requiredAccess +} + // CacheControlConfig is an interface for something that knows how to set cache // control headers type CacheControlConfig interface { diff --git a/utils/http_test.go b/utils/http_test.go index 5e02c89e27..56b9df0beb 100644 --- a/utils/http_test.go +++ b/utils/http_test.go @@ -26,7 +26,7 @@ func MockBetterErrorHandler(ctx context.Context, w http.ResponseWriter, r *http. func TestRootHandlerFactory(t *testing.T) { hand := RootHandlerFactory(context.Background(), nil, &signed.Ed25519{}) - handler := hand(MockContextHandler) + handler := hand(MockContextHandler, NoImageName) if _, ok := interface{}(handler).(http.Handler); !ok { t.Fatalf("A rootHandler must implement the http.Handler interface") } @@ -41,7 +41,7 @@ func TestRootHandlerFactory(t *testing.T) { func TestRootHandlerError(t *testing.T) { hand := RootHandlerFactory(context.Background(), nil, &signed.Ed25519{}) - handler := hand(MockBetterErrorHandler) + handler := hand(MockBetterErrorHandler, NoImageName) ts := httptest.NewServer(handler) defer ts.Close()