diff --git a/pkg/tenant/api/v1/api.go b/pkg/tenant/api/v1/api.go index d9c76cdcc..0fe235dec 100644 --- a/pkg/tenant/api/v1/api.go +++ b/pkg/tenant/api/v1/api.go @@ -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) @@ -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"` diff --git a/pkg/tenant/api/v1/client.go b/pkg/tenant/api/v1/client.go index 712ff9292..5a536da1c 100644 --- a/pkg/tenant/api/v1/client.go +++ b/pkg/tenant/api/v1/client.go @@ -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 { diff --git a/pkg/tenant/api/v1/client_test.go b/pkg/tenant/api/v1/client_test.go index e450dabfe..8fed5c180 100644 --- a/pkg/tenant/api/v1/client_test.go +++ b/pkg/tenant/api/v1/client_test.go @@ -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{ diff --git a/pkg/tenant/tenant.go b/pkg/tenant/tenant.go index 947bccd34..c61d89cde 100644 --- a/pkg/tenant/tenant.go +++ b/pkg/tenant/tenant.go @@ -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 diff --git a/pkg/tenant/tenants.go b/pkg/tenant/tenants.go index cfc9bcf62..007615bdc 100644 --- a/pkg/tenant/tenants.go +++ b/pkg/tenant/tenants.go @@ -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" @@ -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) +} diff --git a/pkg/tenant/tenants_test.go b/pkg/tenant/tenants_test.go index 57e7d4d1b..eb85433e2 100644 --- a/pkg/tenant/tenants_test.go +++ b/pkg/tenant/tenants_test.go @@ -8,10 +8,13 @@ import ( "time" "github.com/oklog/ulid/v2" + qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" + "github.com/rotationalio/ensign/pkg/quarterdeck/mock" perms "github.com/rotationalio/ensign/pkg/quarterdeck/permissions" "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" "github.com/rotationalio/ensign/pkg/tenant/api/v1" "github.com/rotationalio/ensign/pkg/tenant/db" + ulids "github.com/rotationalio/ensign/pkg/utils/ulid" "github.com/trisacrypto/directory/pkg/trtl/pb/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -394,3 +397,182 @@ func (suite *tenantTestSuite) TestTenantDelete() { err = suite.client.TenantDelete(ctx, tenantID) require.NoError(err, "could not delete tenant") } + +func (suite *tenantTestSuite) TestTenantStats() { + require := suite.Require() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Connect to a mock trtl database. + trtl := db.GetMock() + defer trtl.Reset() + + tenantID := "01ARZ3NDEKTSV4RRFFQ69G5FAV" + orgID := "02DEF3NDEKTSV4RRFFQ69G5FAV" + tenant := &db.Tenant{ + OrgID: ulid.MustParse(orgID), + ID: ulid.MustParse(tenantID), + } + + var tenantData []byte + tenantData, err := tenant.MarshalValue() + require.NoError(err, "could not marshal tenant") + + // Trtl mock should return the tenant fixture on Get + trtl.OnGet = func(ctx context.Context, gr *pb.GetRequest) (out *pb.GetReply, err error) { + return &pb.GetReply{ + Value: tenantData, + }, nil + } + + projects := []*db.Project{ + { + OrgID: tenant.OrgID, + TenantID: tenant.ID, + ID: ulids.New(), + }, + { + OrgID: tenant.OrgID, + TenantID: tenant.ID, + ID: ulids.New(), + }, + } + projectPrefix := tenant.ID[:] + + topics := map[string][]*db.Topic{ + string(projects[0].ID[:]): { + { + OrgID: projects[0].OrgID, + ProjectID: projects[0].ID, + ID: ulids.New(), + }, + { + OrgID: projects[0].OrgID, + ProjectID: projects[0].ID, + ID: ulids.New(), + }, + }, + string(projects[1].ID[:]): { + { + OrgID: projects[1].OrgID, + ProjectID: projects[1].ID, + ID: ulids.New(), + }, + }, + } + + // Trtl mock should return projects and topics on Cursor + trtl.OnCursor = func(in *pb.CursorRequest, stream pb.Trtl_CursorServer) error { + switch in.Namespace { + case db.ProjectNamespace: + if !bytes.Equal(in.Prefix, projectPrefix) { + return status.Error(codes.FailedPrecondition, "unexpected prefix for cursor request") + } + for _, project := range projects { + data, err := project.MarshalValue() + require.NoError(err, "could not marshal project fixture data") + stream.Send(&pb.KVPair{ + Key: project.ID[:], + Value: data, + Namespace: in.Namespace, + }) + } + case db.TopicNamespace: + require.Contains(topics, string(in.Prefix), "unexpected prefix for cursor request") + for _, topic := range topics[string(in.Prefix)] { + data, err := topic.MarshalValue() + require.NoError(err, "could not marshal topic fixture data") + stream.Send(&pb.KVPair{ + Key: topic.ID[:], + Value: data, + Namespace: in.Namespace, + }) + } + default: + return status.Error(codes.FailedPrecondition, "unexpected namespace for cursor request") + } + return nil + } + + keys := &qd.APIKeyList{} + + // Initial quarterdeck mock expects authentication and returns 200 with no keys + suite.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusOK), mock.UseJSONFixture(keys), mock.RequireAuth()) + + // Set the initial claims fixture + claims := &tokens.Claims{ + Name: "Leopold Wentzel", + Email: "leopold.wentzel@gmail.com", + Permissions: []string{"read:nothing"}, + } + + // Endpoint must be authenticated + _, err = suite.client.TenantStats(ctx, "invalid") + suite.requireError(err, http.StatusUnauthorized, "this endpoint requires authentication", "expected error when user is not authenticated") + + // User must have the correct permissions + require.NoError(suite.SetClientCredentials(claims), "could not set client credentials") + _, err = suite.client.TenantStats(ctx, "invalid") + suite.requireError(err, http.StatusUnauthorized, "user does not have permission to perform this operation", "expected error when user does not have permission") + + // Set valid permissions for the rest of the tests + claims.Permissions = []string{perms.ReadOrganizations, perms.ReadProjects, perms.ReadTopics, perms.ReadAPIKeys} + require.NoError(suite.SetClientCredentials(claims), "could not set client credentials") + + // Should return an error if the tenant ID is not parseable + _, err = suite.client.TenantStats(ctx, "invalid") + suite.requireError(err, http.StatusBadRequest, "could not parse tenant id", "expected error when tenant ID is not parseable") + + // User should not be able to access a tenant if the orgID is not in the claims + _, err = suite.client.TenantStats(ctx, tenantID) + suite.requireError(err, http.StatusForbidden, "user is not authorized to access this tenant", "expected error when there is no orgID in the claims") + + // User should not be able to access a tenant in another org + claims.OrgID = "03XYZ3NDEKTSV4RRFFQ69G5FAV" + require.NoError(suite.SetClientCredentials(claims), "could not set client credentials") + _, err = suite.client.TenantStats(ctx, tenantID) + suite.requireError(err, http.StatusForbidden, "user is not authorized to access this tenant", "expected error when the orgID in the claims does not match the tenant") + + // Retrieving tenant stats without any keys + claims.OrgID = orgID + expected := &api.TenantStats{ + ID: tenantID, + Projects: 2, + Topics: 3, + } + + require.NoError(suite.SetClientCredentials(claims), "could not set client credentials") + stats, err := suite.client.TenantStats(ctx, tenantID) + require.NoError(err, "could not get tenant stats") + require.Equal(expected, stats, "expected tenant stats to match") + + // Retrieving tenant stats with one page of keys + // TODO: Testing multiple pages requires a more dynamic mock + keys = &qd.APIKeyList{ + APIKeys: []*qd.APIKey{ + { + ID: ulids.New(), + }, + { + ID: ulids.New(), + }, + }, + } + expected.Keys = 4 + suite.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusOK), mock.UseJSONFixture(keys), mock.RequireAuth()) + stats, err = suite.client.TenantStats(ctx, tenantID) + require.NoError(err, "could not get tenant stats") + require.Equal(expected, stats, "expected tenant stats to match") + + // Test that an error is returned if quarterdeck returns an error + suite.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusUnauthorized), mock.RequireAuth()) + _, err = suite.client.TenantStats(ctx, tenantID) + suite.requireError(err, http.StatusUnauthorized, "could not retrieve tenant stats", "expected error when quarterdeck returns an error") + + // Test that an error is returned if the tenant does not exist + trtl.OnGet = func(ctx context.Context, gr *pb.GetRequest) (out *pb.GetReply, err error) { + return nil, status.Error(codes.NotFound, "not found") + } + _, err = suite.client.TenantStats(ctx, tenantID) + suite.requireError(err, http.StatusNotFound, "tenant not found", "expected error when tenant does not exist") +}