Skip to content

Commit

Permalink
Tenant Stats endpoint (#162)
Browse files Browse the repository at this point in the history
  • Loading branch information
pdeziel committed Feb 3, 2023
1 parent 62c459b commit fc2bef2
Show file tree
Hide file tree
Showing 6 changed files with 346 additions and 0 deletions.
10 changes: 10 additions & 0 deletions pkg/tenant/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type TenantClient interface {
TenantMemberList(ctx context.Context, id string, in *PageQuery) (*TenantMemberPage, error)
TenantMemberCreate(ctx context.Context, id string, in *Member) (*Member, error)

TenantStats(ctx context.Context, id string) (*TenantStats, error)

MemberList(context.Context, *PageQuery) (*MemberPage, error)
MemberCreate(context.Context, *Member) (*Member, error)
MemberDetail(ctx context.Context, id string) (*Member, error)
Expand Down Expand Up @@ -166,6 +168,14 @@ type TenantMemberPage struct {
NextPageToken string `json:"next_page_token"`
}

type TenantStats struct {
ID string `json:"id" uri:"id"`
Projects int `json:"projects"`
Topics int `json:"topics"`
Keys int `json:"keys"`
Data int `json:"data"`
}

type Member struct {
ID string `json:"id" uri:"id"`
Name string `json:"name"`
Expand Down
20 changes: 20 additions & 0 deletions pkg/tenant/api/v1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,26 @@ func (s *APIv1) TenantMemberCreate(ctx context.Context, id string, in *Member) (
return out, nil
}

func (s *APIv1) TenantStats(ctx context.Context, id string) (out *TenantStats, err error) {
if id == "" {
return nil, ErrTenantIDRequired
}

path := fmt.Sprintf("/v1/tenant/%s/stats", id)

// Make the HTTP request
var req *http.Request
if req, err = s.NewRequest(ctx, http.MethodGet, path, nil, nil); err != nil {
return nil, err
}

if _, err = s.Do(req, &out, true); err != nil {
return nil, err
}

return out, nil
}

func (s *APIv1) MemberList(ctx context.Context, in *PageQuery) (out *MemberPage, err error) {
var params url.Values
if params, err = query.Values(in); err != nil {
Expand Down
28 changes: 28 additions & 0 deletions pkg/tenant/api/v1/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,34 @@ func TestTenantMemberCreate(t *testing.T) {
require.Equal(t, fixture, out, "unexpected response error")
}

func TestTenantStats(t *testing.T) {
fixture := &api.TenantStats{
ID: "002",
Projects: 2,
Topics: 5,
Keys: 3,
}

// Creates a test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/v1/tenant/002/stats", r.URL.Path)

w.Header().Add("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(fixture)
}))
defer ts.Close()

// Create a client to execute tests against the test server
client, err := api.New(ts.URL)
require.NoError(t, err, "could not create api client")

out, err := client.TenantStats(context.Background(), "002")
require.NoError(t, err, "could not execute api request")
require.Equal(t, fixture, out, "unexpected response body")
}

func TestMemberList(t *testing.T) {
fixture := &api.MemberPage{
Members: []*api.Member{
Expand Down
2 changes: 2 additions & 0 deletions pkg/tenant/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ func (s *Server) setupRoutes() (err error) {

tenant.GET("/:tenantID/projects", mw.Authorize(perms.ReadProjects), s.TenantProjectList)
tenant.POST("/:tenantID/projects", csrf, mw.Authorize(perms.EditProjects), s.TenantProjectCreate)

tenant.GET("/:tenantID/stats", mw.Authorize(perms.ReadOrganizations, perms.ReadProjects, perms.ReadTopics, perms.ReadAPIKeys), s.TenantStats)
}

// Members API routes must be authenticated
Expand Down
104 changes: 104 additions & 0 deletions pkg/tenant/tenants.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package tenant

import (
"context"
"net/http"

"github.com/gin-gonic/gin"
"github.com/oklog/ulid/v2"
qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1"
middleware "github.com/rotationalio/ensign/pkg/quarterdeck/middleware"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
"github.com/rotationalio/ensign/pkg/tenant/api/v1"
Expand Down Expand Up @@ -259,3 +261,105 @@ func (s *Server) TenantDelete(c *gin.Context) {
}
c.Status(http.StatusOK)
}

// TenantStats is a statistical view endpoint which returns high level counts of
// resources associated with a single Tenant.
//
// Route: /tenant/:tenantID/stats
func (s *Server) TenantStats(c *gin.Context) {
var (
claims *tokens.Claims
ctx context.Context
err error
)

// User credentials are required to retrieve api keys from Quarterdeck
if ctx, err = middleware.ContextFromRequest(c); err != nil {
log.Error().Err(err).Msg("could not create user context from request")
c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch credentials for authenticated user"))
return
}

// User claims are required to check ownership of the tenant
if claims, err = middleware.GetClaims(c); err != nil {
log.Error().Err(err).Msg("could not retrieve user claims from context")
c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch claims for authenticated user"))
return
}

// Get the tenantID from the URL
id := c.Param("tenantID")
var tenantID ulid.ULID
if tenantID, err = ulid.Parse(id); err != nil {
log.Error().Str("tenant_id", id).Err(err).Msg("could not parse tenant ulid")
c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse tenant id"))
return
}

// Retrieve the tenant from the database
var tenant *db.Tenant
if tenant, err = db.RetrieveTenant(ctx, tenantID); err != nil {
log.Error().Err(err).Str("tenant_id", id).Msg("could not retrieve tenant")
c.JSON(http.StatusNotFound, api.ErrorResponse("tenant not found"))
return
}

// User should not be able to read a tenant in another organization
if claims.OrgID != tenant.OrgID.String() {
log.Warn().Str("user_org", claims.OrgID).Str("tenant_org", tenant.OrgID.String()).Msg("user cannot access tenant from their current organization")
c.JSON(http.StatusForbidden, api.ErrorResponse("user is not authorized to access this tenant"))
}

// Construct the response with the tenant stats
out := &api.TenantStats{
ID: id,
}

// Number of projects in the tenant
var projects []*db.Project
if projects, err = db.ListProjects(ctx, tenant.ID); err != nil {
log.Error().Err(err).Str("tenant_id", id).Msg("could not retrieve projects in tenant")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not retrieve tenant stats"))
return
}
out.Projects = len(projects)

// Count topics and api keys in each project
for _, project := range projects {
var topics []*db.Topic
if topics, err = db.ListTopics(ctx, project.ID); err != nil {
log.Error().Err(err).Str("project_id", project.ID.String()).Msg("could not retrieve topics in project")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not retrieve tenant stats"))
return
}
out.Topics += len(topics)

// API keys are stored in Quarterdeck
req := &qd.APIPageQuery{
ProjectID: project.ID.String(),
PageSize: 100,
}

// We will always retrieve at least one page; it's possible but unlikely for a
// project to have more than 100 API keys.
keysLoop:
for {
var page *qd.APIKeyList
if page, err = s.quarterdeck.APIKeyList(ctx, req); err != nil {
log.Error().Err(err).Str("project_id", project.ID.String()).Msg("could not retrieve api keys in project")
c.JSON(qd.ErrorStatus(err), api.ErrorResponse("could not retrieve tenant stats"))
return
}
out.Keys += len(page.APIKeys)

if page.NextPageToken == "" {
break keysLoop
}
req.NextPageToken = page.NextPageToken
}
}

// TODO: Add data usage stats

c.JSON(http.StatusOK, out)
}

0 comments on commit fc2bef2

Please sign in to comment.