diff --git a/cmd/server/assets/apikeys/show.html b/cmd/server/assets/apikeys/show.html index bc83d2bea..f1f5acf8a 100644 --- a/cmd/server/assets/apikeys/show.html +++ b/cmd/server/assets/apikeys/show.html @@ -18,9 +18,6 @@ {{template "flash" .}}

{{$authApp.Name}} API key

-

- Edit -

Here is information about the API key.

@@ -40,7 +37,13 @@

{{$authApp.Name}} API key

{{end}}
-
Details
+
+ + Details about {{$authApp.Name}} + + + +
App name
@@ -61,73 +64,110 @@

{{$authApp.Name}} API key

-
Statistics
-
- {{if $stats}} -
- Loading chart... -
- - - - - - - - - {{range $stat := $stats}} - - - - - {{end}} - -
DateKeys issued
{{$stat.Date.Format "2006-01-02"}}{{$stat.CodesIssued}}
-
- This data is refreshed every 5 minutes. +
+ + Statistics +
+
+
+

Loading chart...

- {{else}} -

This app has not recently issued any codes.

- {{end}}
+ + + This data is refreshed every 30 minutes. + Learn more + + + Export as: + CSV + JSON + +
-
-

- ← Back to all API keys -

+ - {{if $stats}} - {{end}} diff --git a/internal/routes/server.go b/internal/routes/server.go index 57ad336d9..41291b95c 100644 --- a/internal/routes/server.go +++ b/internal/routes/server.go @@ -363,6 +363,8 @@ func apikeyRoutes(r *mux.Router, c *apikey.Controller) { r.Handle("/{id:[0-9]+}", c.HandleUpdate()).Methods("PATCH") r.Handle("/{id:[0-9]+}/disable", c.HandleDisable()).Methods("PATCH") r.Handle("/{id:[0-9]+}/enable", c.HandleEnable()).Methods("PATCH") + r.Handle("/{id:[0-9]+}/stats.json", c.HandleStats()).Methods("GET") + r.Handle("/{id:[0-9]+}/stats.csv", c.HandleStats()).Methods("GET") } // userRoutes are the user routes. diff --git a/internal/routes/server_test.go b/internal/routes/server_test.go index 735f92731..e07da3fd6 100644 --- a/internal/routes/server_test.go +++ b/internal/routes/server_test.go @@ -119,6 +119,12 @@ func TestRoutes_apikeyRoutes(t *testing.T) { { req: httptest.NewRequest("PATCH", "/12345/enable", nil), }, + { + req: httptest.NewRequest("GET", "/12345/stats.json", nil), + }, + { + req: httptest.NewRequest("GET", "/12345/stats.csv", nil), + }, } for _, tc := range cases { diff --git a/pkg/controller/apikey/apikey.go b/pkg/controller/apikey/apikey.go index dc386b0e3..76d7364e8 100644 --- a/pkg/controller/apikey/apikey.go +++ b/pkg/controller/apikey/apikey.go @@ -39,3 +39,10 @@ func New(ctx context.Context, config *config.ServerConfig, cacher cache.Cacher, h: h, } } + +func (c *Controller) findAuthorizedApp(currentUser *database.User, realm *database.Realm, id interface{}) (*database.AuthorizedApp, error) { + if currentUser.SystemAdmin { + return c.db.FindAuthorizedApp(id) + } + return realm.FindAuthorizedApp(c.db, id) +} diff --git a/pkg/controller/apikey/disable.go b/pkg/controller/apikey/disable.go index 42ab06d4f..fe71dc9ca 100644 --- a/pkg/controller/apikey/disable.go +++ b/pkg/controller/apikey/disable.go @@ -47,7 +47,7 @@ func (c *Controller) HandleDisable() http.Handler { return } - authApp, err := realm.FindAuthorizedApp(c.db, vars["id"]) + authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"]) if err != nil { if database.IsNotFound(err) { controller.Unauthorized(w, r, c.h) diff --git a/pkg/controller/apikey/enable.go b/pkg/controller/apikey/enable.go index ea8449bc2..cefbd1d48 100644 --- a/pkg/controller/apikey/enable.go +++ b/pkg/controller/apikey/enable.go @@ -46,7 +46,7 @@ func (c *Controller) HandleEnable() http.Handler { return } - authApp, err := realm.FindAuthorizedApp(c.db, vars["id"]) + authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"]) if err != nil { if database.IsNotFound(err) { controller.Unauthorized(w, r, c.h) diff --git a/pkg/controller/apikey/show.go b/pkg/controller/apikey/show.go index 37660eff5..c07c84c43 100644 --- a/pkg/controller/apikey/show.go +++ b/pkg/controller/apikey/show.go @@ -17,11 +17,8 @@ package apikey import ( "context" "net/http" - "strconv" - "time" "github.com/google/exposure-notifications-server/pkg/logging" - "github.com/google/exposure-notifications-verification-server/pkg/cache" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" @@ -48,6 +45,12 @@ func (c *Controller) HandleShow() http.Handler { return } + currentUser := controller.UserFromContext(ctx) + if currentUser == nil { + controller.MissingUser(w, r, c.h) + return + } + // If the API key is present, add it to the variables map and then delete it // from the session. apiKey, ok := session.Values["apiKey"] @@ -58,7 +61,7 @@ func (c *Controller) HandleShow() http.Handler { } // Pull the authorized app from the id. - authApp, err := realm.FindAuthorizedApp(c.db, vars["id"]) + authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"]) if err != nil { if database.IsNotFound(err) { logger.Debugw("auth app does not exist", "id", vars["id"]) @@ -70,30 +73,14 @@ func (c *Controller) HandleShow() http.Handler { return } - // Get and cache the stats for this user. - var stats []*database.AuthorizedAppStats - cacheKey := &cache.Key{ - Namespace: "stats:app", - Key: strconv.FormatUint(uint64(authApp.ID), 10), - } - if err := c.cacher.Fetch(ctx, cacheKey, &stats, 5*time.Minute, func() (interface{}, error) { - now := time.Now().UTC() - past := now.Add(-14 * 24 * time.Hour) - return authApp.Stats(c.db, past, now) - }); err != nil { - controller.InternalError(w, r, c.h, err) - return - } - - c.renderShow(ctx, w, authApp, stats) + c.renderShow(ctx, w, authApp) }) } // renderShow renders the edit page. -func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, authApp *database.AuthorizedApp, stats []*database.AuthorizedAppStats) { +func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, authApp *database.AuthorizedApp) { m := controller.TemplateMapFromContext(ctx) m.Title("API key: %s", authApp.Name) m["authApp"] = authApp - m["stats"] = stats c.h.RenderHTML(w, "apikeys/show", m) } diff --git a/pkg/controller/apikey/stats.go b/pkg/controller/apikey/stats.go new file mode 100644 index 000000000..54eb4897e --- /dev/null +++ b/pkg/controller/apikey/stats.go @@ -0,0 +1,111 @@ +// Copyright 2020 Google LLC +// +// 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 apikey + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/exposure-notifications-server/pkg/logging" + "github.com/google/exposure-notifications-verification-server/internal/project" + "github.com/google/exposure-notifications-verification-server/pkg/cache" + "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/gorilla/mux" +) + +const statsCacheTimeout = 30 * time.Minute + +func (c *Controller) HandleStats() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + + logger := logging.FromContext(ctx).Named("apikey.HandleStats") + + session := controller.SessionFromContext(ctx) + if session == nil { + controller.MissingSession(w, r, c.h) + return + } + + realm := controller.RealmFromContext(ctx) + if realm == nil { + controller.MissingRealm(w, r, c.h) + return + } + + currentUser := controller.UserFromContext(ctx) + if currentUser == nil { + controller.MissingUser(w, r, c.h) + return + } + + // Pull the authorized app from the id. + authApp, err := c.findAuthorizedApp(currentUser, realm, vars["id"]) + if err != nil { + if database.IsNotFound(err) { + logger.Debugw("auth app does not exist", "id", vars["id"]) + controller.Unauthorized(w, r, c.h) + return + } + + controller.InternalError(w, r, c.h, err) + return + } + + stats, err := c.getStats(ctx, authApp, realm) + if err != nil { + controller.InternalError(w, r, c.h, err) + return + } + + pth := r.URL.Path + switch { + case strings.HasSuffix(pth, ".csv"): + nowFormatted := time.Now().UTC().Format(project.RFC3339Squish) + filename := fmt.Sprintf("%s-user-stats.csv", nowFormatted) + c.h.RenderCSV(w, http.StatusOK, filename, stats) + return + case strings.HasSuffix(pth, ".json"): + c.h.RenderJSON(w, http.StatusOK, stats) + return + default: + controller.InternalError(w, r, c.h, fmt.Errorf("unknown path %q", pth)) + return + } + }) +} + +func (c *Controller) getStats(ctx context.Context, authApp *database.AuthorizedApp, realm *database.Realm) (database.AuthorizedAppStats, error) { + now := time.Now().UTC() + past := now.Add(-30 * 24 * time.Hour) + + var stats database.AuthorizedAppStats + cacheKey := &cache.Key{ + Namespace: "stats:app", + Key: strconv.FormatUint(uint64(authApp.ID), 10), + } + if err := c.cacher.Fetch(ctx, cacheKey, &stats, statsCacheTimeout, func() (interface{}, error) { + return authApp.Stats(c.db, past, now) + }); err != nil { + return nil, err + } + return stats, nil +} diff --git a/pkg/database/authorized_app.go b/pkg/database/authorized_app.go index 81e9ed004..9c2d0c765 100644 --- a/pkg/database/authorized_app.go +++ b/pkg/database/authorized_app.go @@ -154,6 +154,21 @@ func (r *Realm) CreateAuthorizedApp(db *Database, app *AuthorizedApp, actor Audi return fullAPIKey, nil } +// FindAuthorizedApp finds the authorized app by the given id. +func (db *Database) FindAuthorizedApp(id interface{}) (*AuthorizedApp, error) { + var app AuthorizedApp + if err := db.db. + Unscoped(). + Model(AuthorizedApp{}). + Order("LOWER(name) ASC"). + Where("id = ?", id). + First(&app). + Error; err != nil { + return nil, err + } + return &app, nil +} + // FindAuthorizedAppByAPIKey located an authorized app based on API key. func (db *Database) FindAuthorizedAppByAPIKey(apiKey string) (*AuthorizedApp, error) { logger := db.logger.Named("FindAuthorizedAppByAPIKey") @@ -204,25 +219,36 @@ func (db *Database) FindAuthorizedAppByAPIKey(apiKey string) (*AuthorizedApp, er // Stats returns the usage statistics for this app. If no stats exist, it // returns an empty array. -func (a *AuthorizedApp) Stats(db *Database, start, stop time.Time) ([]*AuthorizedAppStats, error) { - var stats []*AuthorizedAppStats - +func (a *AuthorizedApp) Stats(db *Database, start, stop time.Time) (AuthorizedAppStats, error) { start = timeutils.UTCMidnight(start) stop = timeutils.UTCMidnight(stop) - if err := db.db. - Model(&AuthorizedAppStats{}). - Where("authorized_app_id = ?", a.ID). - Where("date >= ? AND date <= ?", start, stop). - Order("date DESC"). - Find(&stats). - Error; err != nil { + if start.After(stop) { + return nil, ErrBadDateRange + } + + // Pull the stats by generating the full date range, then join on stats. This + // will ensure we have a full list (with values of 0 where appropriate) to + // ensure continuity in graphs. + sql := ` + SELECT + d.date AS date, + $1 AS authorized_app_id, + $2 AS authorized_app_name, + COALESCE(s.codes_issued, 0) AS codes_issued + FROM ( + SELECT date::date FROM generate_series($3, $4, '1 day'::interval) date + ) d + LEFT JOIN authorized_app_stats s ON s.authorized_app_id = $1 AND s.date = d.date + ORDER BY date DESC` + + var stats []*AuthorizedAppStat + if err := db.db.Raw(sql, a.ID, a.Name, start, stop).Scan(&stats).Error; err != nil { if IsNotFound(err) { return stats, nil } return nil, err } - return stats, nil } diff --git a/pkg/database/authorized_app_stats.go b/pkg/database/authorized_app_stats.go index 5eec15a29..2e72dbcc5 100644 --- a/pkg/database/authorized_app_stats.go +++ b/pkg/database/authorized_app_stats.go @@ -15,18 +15,136 @@ package database import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "sort" + "strconv" "time" + + "github.com/google/exposure-notifications-verification-server/internal/icsv" + "github.com/google/exposure-notifications-verification-server/internal/project" ) -// AuthorizedAppStats represents statistics related to an API key in the +var _ icsv.Marshaler = (AuthorizedAppStats)(nil) + +// AuthorizedAppStats represents a logical collection of stats for an authorized +// app. +type AuthorizedAppStats []*AuthorizedAppStat + +// AuthorizedAppStat represents statistics related to an API key in the // database. -type AuthorizedAppStats struct { - Date time.Time `gorm:"date"` - AuthorizedAppID uint `gorm:"authorized_app_id"` - CodesIssued uint `gorm:"codes_issued"` +type AuthorizedAppStat struct { + Date time.Time `gorm:"date; not null;"` + AuthorizedAppID uint `gorm:"authorized_app_id; not null;"` + CodesIssued uint `gorm:"codes_issued; default: 0;"` + + // Non-database fields, these are added via the stats lookup using the join + // table. + AuthorizedAppName string `gorm:"-"` +} + +// MarshalCSV returns bytes in CSV format. +func (s AuthorizedAppStats) MarshalCSV() ([]byte, error) { + // Do nothing if there's no records + if len(s) == 0 { + return nil, nil + } + + var b bytes.Buffer + w := csv.NewWriter(&b) + + if err := w.Write([]string{"date", "authorized_app_id", "authorized_app_name", "codes_issued"}); err != nil { + return nil, fmt.Errorf("failed to write CSV header: %w", err) + } + + for i, stat := range s { + if err := w.Write([]string{ + stat.Date.Format(project.RFC3339Date), + strconv.FormatUint(uint64(stat.AuthorizedAppID), 10), + stat.AuthorizedAppName, + strconv.FormatUint(uint64(stat.CodesIssued), 10), + }); err != nil { + return nil, fmt.Errorf("failed to write CSV entry %d: %w", i, err) + } + } + + w.Flush() + if err := w.Error(); err != nil { + return nil, fmt.Errorf("failed to create CSV: %w", err) + } + + return b.Bytes(), nil +} + +type jsonAuthorizedAppStat struct { + AuthorizedAppID uint `json:"authorized_app_id"` + AuthorizedAppName string `json:"authorized_app_name"` + Stats []*jsonAuthorizedAppStatstats `json:"statistics"` } -// TableName sets the AuthorizedAppStats table name -func (AuthorizedAppStats) TableName() string { - return "authorized_app_stats" +type jsonAuthorizedAppStatstats struct { + Date time.Time `json:"date"` + Data *jsonAuthorizedAppStatstatsData `json:"data"` +} + +type jsonAuthorizedAppStatstatsData struct { + CodesIssued uint `json:"codes_issued"` +} + +// MarshalJSON is a custom JSON marshaller. +func (s AuthorizedAppStats) MarshalJSON() ([]byte, error) { + // Do nothing if there's no records + if len(s) == 0 { + return json.Marshal(struct{}{}) + } + + var stats []*jsonAuthorizedAppStatstats + for _, stat := range s { + stats = append(stats, &jsonAuthorizedAppStatstats{ + Date: stat.Date, + Data: &jsonAuthorizedAppStatstatsData{ + CodesIssued: stat.CodesIssued, + }, + }) + } + + // Sort in descending order. + sort.Slice(stats, func(i, j int) bool { + return stats[i].Date.After(stats[j].Date) + }) + + var result jsonAuthorizedAppStat + result.AuthorizedAppID = s[0].AuthorizedAppID + result.AuthorizedAppName = s[0].AuthorizedAppName + result.Stats = stats + + b, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal json: %w", err) + } + return b, nil +} + +func (s *AuthorizedAppStats) UnmarshalJSON(b []byte) error { + if len(b) == 0 { + return nil + } + + var result jsonAuthorizedAppStat + if err := json.Unmarshal(b, &result); err != nil { + return err + } + + for _, stat := range result.Stats { + *s = append(*s, &AuthorizedAppStat{ + Date: stat.Date, + AuthorizedAppID: result.AuthorizedAppID, + AuthorizedAppName: result.AuthorizedAppName, + CodesIssued: stat.Data.CodesIssued, + }) + } + + return nil } diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index b5151e1be..7cf2b7d7f 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -415,7 +415,7 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate { ID: "00017-AddIssuerIDColumns", Migrate: func(tx *gorm.DB) error { logger.Debugw("adding issuer id columns to verification codes") - err := tx.AutoMigrate(&VerificationCode{}, &UserStat{}, &AuthorizedAppStats{}).Error + err := tx.AutoMigrate(&VerificationCode{}, &UserStat{}, &AuthorizedAppStat{}).Error return err }, diff --git a/pkg/database/user.go b/pkg/database/user.go index d1b5b66e1..032f46e75 100644 --- a/pkg/database/user.go +++ b/pkg/database/user.go @@ -220,17 +220,19 @@ func (u *User) Stats(db *Database, realmID uint, start, stop time.Time) (UserSta sql := ` SELECT d.date AS date, - $1 AS user_id, - $2 AS realm_id, + $1 AS realm_id, + $2 AS user_id, + $3 AS user_name, + $4 AS user_email, COALESCE(s.codes_issued, 0) AS codes_issued FROM ( - SELECT date::date FROM generate_series($3, $4, '1 day'::interval) date + SELECT date::date FROM generate_series($5, $6, '1 day'::interval) date ) d - LEFT JOIN user_stats s ON s.user_id = $1 AND s.realm_id = $2 AND s.date = d.date + LEFT JOIN user_stats s ON s.user_id = $2 AND s.realm_id = $1 AND s.date = d.date ORDER BY date DESC` var stats []*UserStat - if err := db.db.Raw(sql, u.ID, realmID, start, stop).Scan(&stats).Error; err != nil { + if err := db.db.Raw(sql, realmID, u.ID, u.Name, u.Email, start, stop).Scan(&stats).Error; err != nil { if IsNotFound(err) { return stats, nil } diff --git a/pkg/database/user_stats.go b/pkg/database/user_stats.go index 2780c6046..e0187719f 100644 --- a/pkg/database/user_stats.go +++ b/pkg/database/user_stats.go @@ -39,6 +39,11 @@ type UserStat struct { UserID uint `gorm:"user_id; not null;"` RealmID uint `gorm:"realm_id; default:0;"` CodesIssued uint `gorm:"codes_issued; default:0;"` + + // Non-database fields, these are added via the stats lookup using the join + // table. + UserName string `gorm:"-"` + UserEmail string `gorm:"-"` } // MarshalCSV returns bytes in CSV format. @@ -51,15 +56,17 @@ func (s UserStats) MarshalCSV() ([]byte, error) { var b bytes.Buffer w := csv.NewWriter(&b) - if err := w.Write([]string{"date", "user_id", "realm_id", "codes_issued"}); err != nil { + if err := w.Write([]string{"date", "realm_id", "user_id", "user_name", "user_email", "codes_issued"}); err != nil { return nil, fmt.Errorf("failed to write CSV header: %w", err) } for i, stat := range s { if err := w.Write([]string{ stat.Date.Format(project.RFC3339Date), - strconv.FormatUint(uint64(stat.UserID), 10), strconv.FormatUint(uint64(stat.RealmID), 10), + strconv.FormatUint(uint64(stat.UserID), 10), + stat.UserName, + stat.UserEmail, strconv.FormatUint(uint64(stat.CodesIssued), 10), }); err != nil { return nil, fmt.Errorf("failed to write CSV entry %d: %w", i, err) @@ -75,9 +82,11 @@ func (s UserStats) MarshalCSV() ([]byte, error) { } type jsonUserStat struct { - UserID uint `json:"user_id"` - RealmID uint `json:"realm_id"` - Stats []*jsonUserStatStats `json:"statistics"` + RealmID uint `json:"realm_id"` + UserID uint `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + Stats []*jsonUserStatStats `json:"statistics"` } type jsonUserStatStats struct { @@ -112,8 +121,10 @@ func (s UserStats) MarshalJSON() ([]byte, error) { }) var result jsonUserStat - result.UserID = s[0].UserID result.RealmID = s[0].RealmID + result.UserID = s[0].UserID + result.UserName = s[0].UserName + result.UserEmail = s[0].UserEmail result.Stats = stats b, err := json.Marshal(result) @@ -136,8 +147,10 @@ func (s *UserStats) UnmarshalJSON(b []byte) error { for _, stat := range result.Stats { *s = append(*s, &UserStat{ Date: stat.Date, - UserID: result.UserID, RealmID: result.RealmID, + UserID: result.UserID, + UserName: result.UserName, + UserEmail: result.UserEmail, CodesIssued: stat.Data.CodesIssued, }) } diff --git a/pkg/database/vercode_test.go b/pkg/database/vercode_test.go index 22022f3f4..31724a72a 100644 --- a/pkg/database/vercode_test.go +++ b/pkg/database/vercode_test.go @@ -497,9 +497,9 @@ func TestStatDatesOnCreate(t *testing.T) { } { - var stats []*AuthorizedAppStats + var stats []*AuthorizedAppStat if err := db.db. - Model(&AuthorizedAppStats{}). + Model(&AuthorizedAppStat{}). Select("*"). Scan(&stats). Error; err != nil {