diff --git a/cmd/server/assets/realmadmin/_stats_external_issuers.html b/cmd/server/assets/realmadmin/_stats_external_issuers.html index c4a2dfebb..f7aa937a2 100644 --- a/cmd/server/assets/realmadmin/_stats_external_issuers.html +++ b/cmd/server/assets/realmadmin/_stats_external_issuers.html @@ -14,8 +14,8 @@ Learn more about this table Export as: - CSV - JSON + CSV + JSON @@ -68,7 +68,7 @@ function drawExternalIssuersTable() { $.ajax({ - url: '/stats/realm-external-issuer.json', + url: '/stats/realm/external-issuers.json', data: { scope: 'external' }, dataType: 'json', }) diff --git a/cmd/server/assets/realmadmin/_stats_users.html b/cmd/server/assets/realmadmin/_stats_users.html index 08e2b476c..82df0635a 100644 --- a/cmd/server/assets/realmadmin/_stats_users.html +++ b/cmd/server/assets/realmadmin/_stats_users.html @@ -14,8 +14,8 @@ Learn more about this table Export as: - CSV - JSON + CSV + JSON @@ -60,7 +60,7 @@ function drawUsersTable() { $.ajax({ - url: '/stats/realm-user.json', + url: '/stats/realm/users.json', data: { scope: 'user' }, dataType: 'json', }) diff --git a/cmd/server/assets/users/show.html b/cmd/server/assets/users/show.html index 90e5114a4..468c55591 100644 --- a/cmd/server/assets/users/show.html +++ b/cmd/server/assets/users/show.html @@ -12,6 +12,7 @@ {{template "head" .}} + @@ -79,15 +80,13 @@
Permissions
-
+
Statistics
-
-
-

Loading chart...

-
+
+

Loading chart...

@@ -96,8 +95,8 @@
Permissions
Export as: - CSV - JSON + CSV + JSON
@@ -113,9 +112,8 @@
- - diff --git a/docs/api.md b/docs/api.md index 5e5a0b610..cfd31a5fe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,11 +12,6 @@ - [Client provided UUID to prevent duplicate SMS](#client-provided-uuid-to-prevent-duplicate-sms) - [`/api/batch-issue`](#apibatch-issue) - [Handling batch partial success/failure](#handling-batch-partial-successfailure) - - [`/api/checkcodestatus`](#apicheckcodestatus) - - [`/api/expirecode`](#apiexpirecode) - - [`/api/stats/*` (preview)](#apistats-preview) -- [Chaffing requests](#chaffing-requests) -- [Response codes overview](#response-codes-overview) @@ -156,7 +151,7 @@ Exchange a verification token for a verification certificate (for sending to a k **VerificationCertificateResponse** - ```json +```json { "certificate": "", "error": "", @@ -469,11 +464,16 @@ This path includes realm-level statistics for the past 30 days. - `/api/stats/realm.{csv,json}` - Daily statistics for the realm, including codes issued, codes claimed, and daily active users (if enabled). -- `/api/stats/realm-user.{csv,json}` - Daily statistics for codes issued by +- `/api/stats/realm/users.{csv,json}` - Daily statistics for codes issued by realm user. These statistics only include codes issued by humans logged into the verification system. -- `/api/stats/realm-external-issuser.{csv,json}` - Daily statistics for codes +- `/api/stats/realm/users/:id.{csv,json}` - Daily statistics for codes issued + by the user with the given ID. These statistics only include codes issued by + that human user logged into the verification system for the currently + authorized realm. + +- `/api/stats/realm/external-issusers.{csv,json}` - Daily statistics for codes issued by external issuers. These statistics only include codes issued by the API where an `externalIssuer` field was provided. diff --git a/internal/routes/adminapi.go b/internal/routes/adminapi.go index ba795025f..92994c0ec 100644 --- a/internal/routes/adminapi.go +++ b/internal/routes/adminapi.go @@ -119,10 +119,15 @@ func AdminAPI( statsController := stats.New(ctx, cacher, db, h) sub.Handle("/realm.csv", statsController.HandleRealmStats(stats.StatsTypeCSV)).Methods("GET") sub.Handle("/realm.json", statsController.HandleRealmStats(stats.StatsTypeJSON)).Methods("GET") - sub.Handle("/realm-user.csv", statsController.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET") - sub.Handle("/realm-user.json", statsController.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET") - sub.Handle("/realm-external-issuer.csv", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeCSV)).Methods("GET") - sub.Handle("/realm-external-issuer.json", statsController.HandleRealmExternalIssuerStats(stats.StatsTypeJSON)).Methods("GET") + + sub.Handle("/realm/users.csv", statsController.HandleRealmUsersStats(stats.StatsTypeCSV)).Methods("GET") + sub.Handle("/realm/users.json", statsController.HandleRealmUsersStats(stats.StatsTypeJSON)).Methods("GET") + + sub.Handle("/realm/users/{id}.csv", statsController.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET") + sub.Handle("/realm/users/{id}.json", statsController.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET") + + sub.Handle("/realm/external-issuers.csv", statsController.HandleRealmExternalIssuersStats(stats.StatsTypeCSV)).Methods("GET") + sub.Handle("/realm/external-issuers.json", statsController.HandleRealmExternalIssuersStats(stats.StatsTypeJSON)).Methods("GET") } // Wrap the main router in the mutating middleware method. This cannot be diff --git a/internal/routes/server.go b/internal/routes/server.go index be89bdfe0..7e6340756 100644 --- a/internal/routes/server.go +++ b/internal/routes/server.go @@ -377,8 +377,6 @@ func userRoutes(r *mux.Router, c *user.Controller) { r.Handle("/{id:[0-9]+}", c.HandleShow()).Methods("GET") r.Handle("/{id:[0-9]+}", c.HandleUpdate()).Methods("PATCH") r.Handle("/{id:[0-9]+}", c.HandleDelete()).Methods("DELETE") - r.Handle("/{id:[0-9]+}/stats.json", c.HandleUserStats()).Methods("GET") - r.Handle("/{id:[0-9]+}/stats.csv", c.HandleUserStats()).Methods("GET") r.Handle("/{id:[0-9]+}/reset-password", c.HandleResetPassword()).Methods("POST") } @@ -396,10 +394,15 @@ func realmkeysRoutes(r *mux.Router, c *realmkeys.Controller) { func statsRoutes(r *mux.Router, c *stats.Controller) { r.Handle("/realm.csv", c.HandleRealmStats(stats.StatsTypeCSV)).Methods("GET") r.Handle("/realm.json", c.HandleRealmStats(stats.StatsTypeJSON)).Methods("GET") - r.Handle("/realm-user.csv", c.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET") - r.Handle("/realm-user.json", c.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET") - r.Handle("/realm-external-issuer.csv", c.HandleRealmExternalIssuerStats(stats.StatsTypeCSV)).Methods("GET") - r.Handle("/realm-external-issuer.json", c.HandleRealmExternalIssuerStats(stats.StatsTypeJSON)).Methods("GET") + + r.Handle("/realm/users.csv", c.HandleRealmUsersStats(stats.StatsTypeCSV)).Methods("GET") + r.Handle("/realm/users.json", c.HandleRealmUsersStats(stats.StatsTypeJSON)).Methods("GET") + + r.Handle("/realm/users/{id}.csv", c.HandleRealmUserStats(stats.StatsTypeCSV)).Methods("GET") + r.Handle("/realm/users/{id}.json", c.HandleRealmUserStats(stats.StatsTypeJSON)).Methods("GET") + + r.Handle("/realm/external-issuers.csv", c.HandleRealmExternalIssuersStats(stats.StatsTypeCSV)).Methods("GET") + r.Handle("/realm/external-issuers.json", c.HandleRealmExternalIssuersStats(stats.StatsTypeJSON)).Methods("GET") } // realmadminRoutes are the realm admin routes. diff --git a/internal/routes/server_test.go b/internal/routes/server_test.go index ac8067184..11bbda5eb 100644 --- a/internal/routes/server_test.go +++ b/internal/routes/server_test.go @@ -163,12 +163,6 @@ func TestRoutes_userRoutes(t *testing.T) { { req: httptest.NewRequest("DELETE", "/12345", nil), }, - { - req: httptest.NewRequest("GET", "/12345/stats.json", nil), - }, - { - req: httptest.NewRequest("GET", "/12345/stats.csv", nil), - }, { req: httptest.NewRequest("POST", "/12345/reset-password", nil), }, @@ -232,16 +226,22 @@ func TestRoutes_statsRoutes(t *testing.T) { req: httptest.NewRequest("GET", "/realm.json", nil), }, { - req: httptest.NewRequest("GET", "/realm-user.csv", nil), + req: httptest.NewRequest("GET", "/realm/users.csv", nil), + }, + { + req: httptest.NewRequest("GET", "/realm/users.json", nil), + }, + { + req: httptest.NewRequest("GET", "/realm/users/12345.csv", nil), }, { - req: httptest.NewRequest("GET", "/realm-user.json", nil), + req: httptest.NewRequest("GET", "/realm/users/12345.json", nil), }, { - req: httptest.NewRequest("GET", "/realm-external-issuer.csv", nil), + req: httptest.NewRequest("GET", "/realm/external-issuers.csv", nil), }, { - req: httptest.NewRequest("GET", "/realm-external-issuer.json", nil), + req: httptest.NewRequest("GET", "/realm/external-issuers.json", nil), }, } diff --git a/pkg/controller/stats/realm.go b/pkg/controller/stats/realm.go index 3d8fb4e75..a643cb42d 100644 --- a/pkg/controller/stats/realm.go +++ b/pkg/controller/stats/realm.go @@ -19,6 +19,7 @@ import ( "net/http" "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/rbac" ) // HandleRealmStats renders statistics for the current realm. @@ -26,7 +27,7 @@ func (c *Controller) HandleRealmStats(typ StatsType) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - currentRealm, ok := authorizeFromContext(ctx) + currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead) if !ok { controller.Unauthorized(w, r, c.h) return diff --git a/pkg/controller/stats/realm_external_issuer.go b/pkg/controller/stats/realm_external_issuer.go index 2507b07f2..417d5ceb5 100644 --- a/pkg/controller/stats/realm_external_issuer.go +++ b/pkg/controller/stats/realm_external_issuer.go @@ -19,14 +19,15 @@ import ( "net/http" "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/rbac" ) -// HandleRealmExternalIssuerStats renders statistics for the current realm. -func (c *Controller) HandleRealmExternalIssuerStats(typ StatsType) http.Handler { +// HandleRealmExternalIssuersStats renders statistics for the current realm. +func (c *Controller) HandleRealmExternalIssuersStats(typ StatsType) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - currentRealm, ok := authorizeFromContext(ctx) + currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead) if !ok { controller.Unauthorized(w, r, c.h) return diff --git a/pkg/controller/stats/realm_user.go b/pkg/controller/stats/realm_user.go index 36de230bc..a56732536 100644 --- a/pkg/controller/stats/realm_user.go +++ b/pkg/controller/stats/realm_user.go @@ -17,22 +17,42 @@ package stats import ( "net/http" + "regexp" + "strings" "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/database" + "github.com/google/exposure-notifications-verification-server/pkg/rbac" + "github.com/gorilla/mux" ) -// HandleRealmUserStats renders statistics for the current realm. +// HandleRealmUserStats renders statistics for a single user in the current +// realm. func (c *Controller) HandleRealmUserStats(typ StatsType) http.Handler { + re := regexp.MustCompile(`[^A-Za-z0-9]`) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + vars := mux.Vars(r) - currentRealm, ok := authorizeFromContext(ctx) + currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead, rbac.UserRead) if !ok { controller.Unauthorized(w, r, c.h) return } - stats, err := currentRealm.UserStatsCached(ctx, c.db, c.cacher) + user, err := currentRealm.FindUser(c.db, vars["id"]) + if err != nil { + if database.IsNotFound(err) { + controller.Unauthorized(w, r, c.h) + return + } + + controller.InternalError(w, r, c.h, err) + return + } + + stats, err := user.StatsCached(ctx, c.db, c.cacher, currentRealm) if err != nil { controller.InternalError(w, r, c.h, err) return @@ -40,7 +60,8 @@ func (c *Controller) HandleRealmUserStats(typ StatsType) http.Handler { switch typ { case StatsTypeCSV: - c.h.RenderCSV(w, http.StatusOK, csvFilename("user-stats"), stats) + filename := re.ReplaceAllString(strings.ToLower(user.Name), "-") + c.h.RenderCSV(w, http.StatusOK, csvFilename(filename), stats) return case StatsTypeJSON: c.h.RenderJSON(w, http.StatusOK, stats) diff --git a/pkg/controller/stats/realm_users.go b/pkg/controller/stats/realm_users.go new file mode 100644 index 000000000..df11c774d --- /dev/null +++ b/pkg/controller/stats/realm_users.go @@ -0,0 +1,54 @@ +// 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 stats produces statistics. +package stats + +import ( + "net/http" + + "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/rbac" +) + +// HandleRealmUsersStats renders statistics for the current realm. +func (c *Controller) HandleRealmUsersStats(typ StatsType) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + currentRealm, ok := authorizeFromContext(ctx, rbac.StatsRead) + if !ok { + controller.Unauthorized(w, r, c.h) + return + } + + stats, err := currentRealm.UserStatsCached(ctx, c.db, c.cacher) + if err != nil { + controller.InternalError(w, r, c.h, err) + return + } + + switch typ { + case StatsTypeCSV: + c.h.RenderCSV(w, http.StatusOK, csvFilename("user-stats"), stats) + return + case StatsTypeJSON: + c.h.RenderJSON(w, http.StatusOK, stats) + return + default: + controller.NotFound(w, r, c.h) + return + } + }) +} diff --git a/pkg/controller/stats/stats.go b/pkg/controller/stats/stats.go index 622ad1598..b9e9ee9a7 100644 --- a/pkg/controller/stats/stats.go +++ b/pkg/controller/stats/stats.go @@ -54,11 +54,16 @@ func New(ctx context.Context, cacher cache.Cacher, db *database.Database, h rend } // authorizeFromContext attempts to pull authorization from the context. It -// returns false if authorization failed. -func authorizeFromContext(ctx context.Context) (*database.Realm, bool) { +// returns false if authorization failed. The authorization must have one of the +// provided permissions to succeed. +func authorizeFromContext(ctx context.Context, permissions ...rbac.Permission) (*database.Realm, bool) { membership := controller.MembershipFromContext(ctx) - if membership != nil && membership.Can(rbac.StatsRead) { - return membership.Realm, true + if membership != nil { + for _, permission := range permissions { + if membership.Can(permission) { + return membership.Realm, true + } + } } realm := controller.RealmFromContext(ctx) diff --git a/pkg/controller/user/stats.go b/pkg/controller/user/stats.go deleted file mode 100644 index de1a575e3..000000000 --- a/pkg/controller/user/stats.go +++ /dev/null @@ -1,86 +0,0 @@ -// 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 user - -import ( - "fmt" - "net/http" - "strings" - "time" - - "github.com/google/exposure-notifications-verification-server/internal/project" - "github.com/google/exposure-notifications-verification-server/pkg/controller" - "github.com/google/exposure-notifications-verification-server/pkg/database" - "github.com/google/exposure-notifications-verification-server/pkg/rbac" - "github.com/gorilla/mux" -) - -func (c *Controller) HandleUserStats() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - vars := mux.Vars(r) - - session := controller.SessionFromContext(ctx) - if session == nil { - controller.MissingSession(w, r, c.h) - return - } - - membership := controller.MembershipFromContext(ctx) - if membership == nil { - controller.MissingMembership(w, r, c.h) - return - } - if !membership.Can(rbac.UserRead) { - controller.Unauthorized(w, r, c.h) - return - } - currentRealm := membership.Realm - currentUser := membership.User - - // Pull the user from the id. - user, _, err := c.findUser(currentUser, currentRealm, vars["id"]) - if err != nil { - if database.IsNotFound(err) { - controller.Unauthorized(w, r, c.h) - return - } - - controller.InternalError(w, r, c.h, err) - return - } - - stats, err := user.StatsCached(ctx, c.db, c.cacher, currentRealm) - 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.BadRequest(w, r, c.h) - return - } - }) -}