diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d95cf43748b69..58196165db9ce 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2813,6 +2813,48 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members/{user}/workspace-quota": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get workspace quota by user", + "operationId": "get-workspace-quota-by-user", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceQuota" + } + } + } + } + }, "/organizations/{organization}/members/{user}/workspaces": { "post": { "security": [ @@ -6258,8 +6300,9 @@ const docTemplate = `{ "tags": [ "Enterprise" ], - "summary": "Get workspace quota by user", - "operationId": "get-workspace-quota-by-user", + "summary": "Get workspace quota by user deprecated", + "operationId": "get-workspace-quota-by-user-deprecated", + "deprecated": true, "parameters": [ { "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6aa6232b6ced2..8e53c856296fc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2465,6 +2465,44 @@ } } }, + "/organizations/{organization}/members/{user}/workspace-quota": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get workspace quota by user", + "operationId": "get-workspace-quota-by-user", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceQuota" + } + } + } + } + }, "/organizations/{organization}/members/{user}/workspaces": { "post": { "security": [ @@ -5524,8 +5562,9 @@ ], "produces": ["application/json"], "tags": ["Enterprise"], - "summary": "Get workspace quota by user", - "operationId": "get-workspace-quota-by-user", + "summary": "Get workspace quota by user deprecated", + "operationId": "get-workspace-quota-by-user-deprecated", + "deprecated": true, "parameters": [ { "type": "string", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5491cc020991b..3cbf2e88ce6a5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1823,20 +1823,20 @@ func (q *querier) GetProvisionerLogsAfterID(ctx context.Context, arg database.Ge return q.db.GetProvisionerLogsAfterID(ctx, arg) } -func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) { - err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID)) +func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, params database.GetQuotaAllowanceForUserParams) (int64, error) { + err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(params.UserID)) if err != nil { return -1, err } - return q.db.GetQuotaAllowanceForUser(ctx, userID) + return q.db.GetQuotaAllowanceForUser(ctx, params) } -func (q *querier) GetQuotaConsumedForUser(ctx context.Context, userID uuid.UUID) (int64, error) { - err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID)) +func (q *querier) GetQuotaConsumedForUser(ctx context.Context, params database.GetQuotaConsumedForUserParams) (int64, error) { + err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(params.OwnerID)) if err != nil { return -1, err } - return q.db.GetQuotaConsumedForUser(ctx, userID) + return q.db.GetQuotaConsumedForUser(ctx, params) } func (q *querier) GetReplicaByID(ctx context.Context, id uuid.UUID) (database.Replica, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 80f5665a3df87..4444cd9a31f10 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1075,11 +1075,17 @@ func (s *MethodTestSuite) TestUser() { })) s.Run("GetQuotaAllowanceForUser", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) - check.Args(u.ID).Asserts(u, policy.ActionRead).Returns(int64(0)) + check.Args(database.GetQuotaAllowanceForUserParams{ + UserID: u.ID, + OrganizationID: uuid.New(), + }).Asserts(u, policy.ActionRead).Returns(int64(0)) })) s.Run("GetQuotaConsumedForUser", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) - check.Args(u.ID).Asserts(u, policy.ActionRead).Returns(int64(0)) + check.Args(database.GetQuotaConsumedForUserParams{ + OwnerID: u.ID, + OrganizationID: uuid.New(), + }).Asserts(u, policy.ActionRead).Returns(int64(0)) })) s.Run("GetUserByEmailOrUsername", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 1676d3c13d986..675847605fea3 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3314,13 +3314,13 @@ func (q *FakeQuerier) GetProvisionerLogsAfterID(_ context.Context, arg database. return logs, nil } -func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UUID) (int64, error) { +func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, params database.GetQuotaAllowanceForUserParams) (int64, error) { q.mutex.RLock() defer q.mutex.RUnlock() var sum int64 for _, member := range q.groupMembers { - if member.UserID != userID { + if member.UserID != params.UserID { continue } if _, err := q.getOrganizationByIDNoLock(member.GroupID); err == nil { @@ -3340,7 +3340,7 @@ func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UU // Grab the quota for the Everyone group iff the user is a member of // said organization. for _, mem := range q.organizationMembers { - if mem.UserID != userID { + if mem.UserID != params.UserID { continue } @@ -3348,19 +3348,25 @@ func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UU if err != nil { return -1, xerrors.Errorf("failed to get everyone group for org %q", mem.OrganizationID.String()) } + if group.OrganizationID != params.OrganizationID { + continue + } sum += int64(group.QuotaAllowance) } return sum, nil } -func (q *FakeQuerier) GetQuotaConsumedForUser(_ context.Context, userID uuid.UUID) (int64, error) { +func (q *FakeQuerier) GetQuotaConsumedForUser(_ context.Context, params database.GetQuotaConsumedForUserParams) (int64, error) { q.mutex.RLock() defer q.mutex.RUnlock() var sum int64 for _, workspace := range q.workspaces { - if workspace.OwnerID != userID { + if workspace.OwnerID != params.OwnerID { + continue + } + if workspace.OrganizationID != params.OrganizationID { continue } if workspace.Deleted { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 991c31617ac96..ec8cda5503c5d 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -963,14 +963,14 @@ func (m metricsStore) GetProvisionerLogsAfterID(ctx context.Context, arg databas return logs, err } -func (m metricsStore) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) { +func (m metricsStore) GetQuotaAllowanceForUser(ctx context.Context, userID database.GetQuotaAllowanceForUserParams) (int64, error) { start := time.Now() allowance, err := m.s.GetQuotaAllowanceForUser(ctx, userID) m.queryLatencies.WithLabelValues("GetQuotaAllowanceForUser").Observe(time.Since(start).Seconds()) return allowance, err } -func (m metricsStore) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) { +func (m metricsStore) GetQuotaConsumedForUser(ctx context.Context, ownerID database.GetQuotaConsumedForUserParams) (int64, error) { start := time.Now() consumed, err := m.s.GetQuotaConsumedForUser(ctx, ownerID) m.queryLatencies.WithLabelValues("GetQuotaConsumedForUser").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index c3660c7bf7afd..1fb2811cc3ba2 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1960,7 +1960,7 @@ func (mr *MockStoreMockRecorder) GetProvisionerLogsAfterID(arg0, arg1 any) *gomo } // GetQuotaAllowanceForUser mocks base method. -func (m *MockStore) GetQuotaAllowanceForUser(arg0 context.Context, arg1 uuid.UUID) (int64, error) { +func (m *MockStore) GetQuotaAllowanceForUser(arg0 context.Context, arg1 database.GetQuotaAllowanceForUserParams) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetQuotaAllowanceForUser", arg0, arg1) ret0, _ := ret[0].(int64) @@ -1975,7 +1975,7 @@ func (mr *MockStoreMockRecorder) GetQuotaAllowanceForUser(arg0, arg1 any) *gomoc } // GetQuotaConsumedForUser mocks base method. -func (m *MockStore) GetQuotaConsumedForUser(arg0 context.Context, arg1 uuid.UUID) (int64, error) { +func (m *MockStore) GetQuotaConsumedForUser(arg0 context.Context, arg1 database.GetQuotaConsumedForUserParams) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetQuotaConsumedForUser", arg0, arg1) ret0, _ := ret[0].(int64) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5fb44c3f2fbcd..c4f948361e54c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -194,8 +194,8 @@ type sqlcQuerier interface { GetProvisionerKeyByID(ctx context.Context, id uuid.UUID) (ProvisionerKey, error) GetProvisionerKeyByName(ctx context.Context, arg GetProvisionerKeyByNameParams) (ProvisionerKey, error) GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) - GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) - GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) + GetQuotaAllowanceForUser(ctx context.Context, arg GetQuotaAllowanceForUserParams) (int64, error) + GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error) GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 73686593afc3b..3ae9c89444aa0 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -604,7 +604,10 @@ func TestWorkspaceQuotas(t *testing.T) { db2sdk.List([]database.OrganizationMember{memOne, memTwo}, orgMemberIDs)) // Check the quota is correct. - allowance, err := db.GetQuotaAllowanceForUser(ctx, one.ID) + allowance, err := db.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{ + UserID: one.ID, + OrganizationID: org.ID, + }) require.NoError(t, err) require.Equal(t, int64(50), allowance) @@ -617,7 +620,10 @@ func TestWorkspaceQuotas(t *testing.T) { require.NoError(t, err) // Ensure allowance remains the same - allowance, err = db.GetQuotaAllowanceForUser(ctx, one.ID) + allowance, err = db.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{ + UserID: one.ID, + OrganizationID: org.ID, + }) require.NoError(t, err) require.Equal(t, int64(50), allowance) }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8927770dad4f8..62ddd6b90ed39 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6165,14 +6165,22 @@ FROM ( -- Select all groups this user is a member of. This will also include -- the "Everyone" group for organizations the user is a member of. - SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded WHERE $1 = user_id + SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded + WHERE + $1 = user_id AND + $2 = group_members_expanded.organization_id ) AS members INNER JOIN groups ON members.group_id = groups.id ` -func (q *sqlQuerier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) { - row := q.db.QueryRowContext(ctx, getQuotaAllowanceForUser, userID) +type GetQuotaAllowanceForUserParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (q *sqlQuerier) GetQuotaAllowanceForUser(ctx context.Context, arg GetQuotaAllowanceForUserParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getQuotaAllowanceForUser, arg.UserID, arg.OrganizationID) var column_1 int64 err := row.Scan(&column_1) return column_1, err @@ -6197,11 +6205,19 @@ FROM workspaces JOIN latest_builds ON latest_builds.workspace_id = workspaces.id -WHERE NOT deleted AND workspaces.owner_id = $1 +WHERE NOT + deleted AND + workspaces.owner_id = $1 AND + workspaces.organization_id = $2 ` -func (q *sqlQuerier) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) { - row := q.db.QueryRowContext(ctx, getQuotaConsumedForUser, ownerID) +type GetQuotaConsumedForUserParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (q *sqlQuerier) GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getQuotaConsumedForUser, arg.OwnerID, arg.OrganizationID) var column_1 int64 err := row.Scan(&column_1) return column_1, err diff --git a/coderd/database/queries/quotas.sql b/coderd/database/queries/quotas.sql index 95679822c4f22..48f9209783e4e 100644 --- a/coderd/database/queries/quotas.sql +++ b/coderd/database/queries/quotas.sql @@ -5,7 +5,10 @@ FROM ( -- Select all groups this user is a member of. This will also include -- the "Everyone" group for organizations the user is a member of. - SELECT * FROM group_members_expanded WHERE @user_id = user_id + SELECT * FROM group_members_expanded + WHERE + @user_id = user_id AND + @organization_id = group_members_expanded.organization_id ) AS members INNER JOIN groups ON members.group_id = groups.id @@ -30,4 +33,8 @@ FROM workspaces JOIN latest_builds ON latest_builds.workspace_id = workspaces.id -WHERE NOT deleted AND workspaces.owner_id = $1; +WHERE NOT + deleted AND + workspaces.owner_id = @owner_id AND + workspaces.organization_id = @organization_id +; diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 1864a97a0c418..4e4b98fe8c243 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -572,8 +572,8 @@ type WorkspaceQuota struct { Budget int `json:"budget"` } -func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil) +func (c *Client) WorkspaceQuota(ctx context.Context, organizationID string, userID string) (WorkspaceQuota, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s/workspace-quota", organizationID, userID), nil) if err != nil { return WorkspaceQuota{}, err } diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index d1df96eee1016..18ae4e7de61d5 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1405,6 +1405,45 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace quota by user + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/workspace-quota \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/members/{user}/workspace-quota` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------------ | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | +| `organization` | path | string(uuid) | true | Organization ID | + +### Example responses + +> 200 Response + +```json +{ + "budget": 0, + "credits_consumed": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceQuota](schemas.md#codersdkworkspacequota) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get provisioner daemons ### Code samples @@ -2301,7 +2340,7 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get workspace quota by user +## Get workspace quota by user deprecated ### Code samples diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 7707b3dc4c808..effe2aa2ce66c 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -275,6 +275,19 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Delete("/organizations/{organization}/members/roles/{roleName}", api.deleteOrgRole) }) + r.Group(func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractOrganizationParam(api.Database), + // Intentionally using ExtractUser instead of ExtractMember. + // It is possible for a member to be removed from an org, in which + // case their orphaned workspaces still exist. We only need + // the user_id for the query. + httpmw.ExtractUserParam(api.Database), + ) + r.Get("/organizations/{organization}/members/{user}/workspace-quota", api.workspaceQuota) + }) + r.Route("/organizations/{organization}/groups", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -365,7 +378,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/", api.workspaceQuota) + r.Get("/", api.workspaceQuotaByUser) }) }) r.Route("/appearance", func(r chi.Router) { diff --git a/enterprise/coderd/workspacequota.go b/enterprise/coderd/workspacequota.go index d11111edac388..f93ab4ffc4793 100644 --- a/enterprise/coderd/workspacequota.go +++ b/enterprise/coderd/workspacequota.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "cdr.dev/slog" @@ -13,7 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionerd/proto" ) @@ -48,12 +48,18 @@ func (c *committer) CommitQuota( ) err = c.Database.InTx(func(s database.Store) error { var err error - consumed, err = s.GetQuotaConsumedForUser(ctx, workspace.OwnerID) + consumed, err = s.GetQuotaConsumedForUser(ctx, database.GetQuotaConsumedForUserParams{ + OwnerID: workspace.OwnerID, + OrganizationID: workspace.OrganizationID, + }) if err != nil { return err } - budget, err = s.GetQuotaAllowanceForUser(ctx, workspace.OwnerID) + budget, err = s.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{ + UserID: workspace.OwnerID, + OrganizationID: workspace.OrganizationID, + }) if err != nil { return err } @@ -112,22 +118,43 @@ func (c *committer) CommitQuota( }, nil } -// @Summary Get workspace quota by user -// @ID get-workspace-quota-by-user +// @Summary Get workspace quota by user deprecated +// @ID get-workspace-quota-by-user-deprecated // @Security CoderSessionToken // @Produce json // @Tags Enterprise // @Param user path string true "User ID, name, or me" // @Success 200 {object} codersdk.WorkspaceQuota // @Router /workspace-quota/{user} [get] -func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { - user := httpmw.UserParam(r) - - if !api.AGPL.Authorize(r, policy.ActionRead, user) { - httpapi.ResourceNotFound(rw) +// @Deprecated this endpoint will be removed, use /organizations/{organization}/members/{user}/workspace-quota instead +func (api *API) workspaceQuotaByUser(rw http.ResponseWriter, r *http.Request) { + defaultOrg, err := api.Database.GetDefaultOrganization(r.Context()) + if err != nil { + httpapi.InternalServerError(rw, err) return } + // defer to the new endpoint using default org as the organization + chi.RouteContext(r.Context()).URLParams.Add("organization", defaultOrg.ID.String()) + mw := httpmw.ExtractOrganizationParam(api.Database) + mw(http.HandlerFunc(api.workspaceQuota)).ServeHTTP(rw, r) +} + +// @Summary Get workspace quota by user +// @ID get-workspace-quota-by-user +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID, name, or me" +// @Param organization path string true "Organization ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceQuota +// @Router /organizations/{organization}/members/{user}/workspace-quota [get] +func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { + var ( + organization = httpmw.OrganizationParam(r) + user = httpmw.UserParam(r) + ) + api.entitlementsMu.RLock() licensed := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled api.entitlementsMu.RUnlock() @@ -136,7 +163,10 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { var quotaAllowance int64 = -1 if licensed { var err error - quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), user.ID) + quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), database.GetQuotaAllowanceForUserParams{ + UserID: user.ID, + OrganizationID: organization.ID, + }) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get allowance", @@ -146,7 +176,10 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { } } - quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), user.ID) + quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), database.GetQuotaConsumedForUserParams{ + OwnerID: user.ID, + OrganizationID: organization.ID, + }) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get consumed", diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index 0b375d5c1d9b0..fdb70c154fa19 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -2,6 +2,9 @@ package coderd_test import ( "context" + "encoding/json" + "fmt" + "net/http" "sync" "testing" @@ -20,15 +23,31 @@ import ( "github.com/coder/coder/v2/testutil" ) -func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, consumed, total int) { +func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID string, consumed, total int) { t.Helper() - got, err := client.WorkspaceQuota(ctx, codersdk.Me) + got, err := client.WorkspaceQuota(ctx, organizationID, codersdk.Me) require.NoError(t, err) require.EqualValues(t, codersdk.WorkspaceQuota{ Budget: total, CreditsConsumed: consumed, }, got) + + // Remove this check when the deprecated endpoint is removed. + // This just makes sure the deprecated endpoint is still working + // as intended. It will only work for the default organization. + deprecatedGot, err := deprecatedQuotaEndpoint(ctx, client, codersdk.Me) + require.NoError(t, err, "deprecated endpoint") + // Only continue to check if the values differ + if deprecatedGot.Budget != got.Budget || deprecatedGot.CreditsConsumed != got.CreditsConsumed { + org, err := client.OrganizationByName(ctx, organizationID) + if err != nil { + return + } + if org.IsDefault { + require.Equal(t, got, deprecatedGot) + } + } } func TestWorkspaceQuota(t *testing.T) { @@ -52,14 +71,14 @@ func TestWorkspaceQuota(t *testing.T) { }) coderdtest.NewProvisionerDaemon(t, api.AGPL) - verifyQuota(ctx, t, client, 0, 0) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0) // Patch the 'Everyone' group to verify its quota allowance is being accounted for. _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ QuotaAllowance: ptr.Ref(1), }) require.NoError(t, err) - verifyQuota(ctx, t, client, 0, 1) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 1) // Add user to two groups, granting them a total budget of 4. group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -84,7 +103,7 @@ func TestWorkspaceQuota(t *testing.T) { }) require.NoError(t, err) - verifyQuota(ctx, t, client, 0, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 4) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ @@ -123,14 +142,14 @@ func TestWorkspaceQuota(t *testing.T) { }() } wg.Wait() - verifyQuota(ctx, t, client, 4, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) // Next one must fail workspace := coderdtest.CreateWorkspace(t, client, template.ID) build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) // Consumed shouldn't bump - verifyQuota(ctx, t, client, 4, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) require.Contains(t, build.Job.Error, "quota") @@ -146,7 +165,7 @@ func TestWorkspaceQuota(t *testing.T) { }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - verifyQuota(ctx, t, client, 3, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 3, 4) break } @@ -154,7 +173,7 @@ func TestWorkspaceQuota(t *testing.T) { workspace = coderdtest.CreateWorkspace(t, client, template.ID) build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - verifyQuota(ctx, t, client, 4, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) }) @@ -174,14 +193,14 @@ func TestWorkspaceQuota(t *testing.T) { }) coderdtest.NewProvisionerDaemon(t, api.AGPL) - verifyQuota(ctx, t, client, 0, 0) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0) // Patch the 'Everyone' group to verify its quota allowance is being accounted for. _, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{ QuotaAllowance: ptr.Ref(4), }) require.NoError(t, err) - verifyQuota(ctx, t, client, 0, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 4) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -208,7 +227,7 @@ func TestWorkspaceQuota(t *testing.T) { assert.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) } wg.Wait() - verifyQuota(ctx, t, client, 4, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) // Next one must fail workspace := coderdtest.CreateWorkspace(t, client, template.ID) @@ -216,21 +235,21 @@ func TestWorkspaceQuota(t *testing.T) { require.Contains(t, build.Job.Error, "quota") // Consumed shouldn't bump - verifyQuota(ctx, t, client, 4, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status) build = coderdtest.CreateWorkspaceBuild(t, client, workspaces[0], database.WorkspaceTransitionStop) build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) // Quota goes down one - verifyQuota(ctx, t, client, 3, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 3, 4) require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status) build = coderdtest.CreateWorkspaceBuild(t, client, workspaces[0], database.WorkspaceTransitionStart) build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) // Quota goes back up - verifyQuota(ctx, t, client, 4, 4) + verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4) require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status) }) @@ -273,13 +292,27 @@ func TestWorkspaceQuota(t *testing.T) { }) require.NoError(t, err) - verifyQuota(ctx, t, member, 0, 30) - // This currently reports the total site wide quotas. We might want to - // org scope this api call in the future. - verifyQuota(ctx, t, owner, 0, 45) + verifyQuota(ctx, t, member, first.OrganizationID.String(), 0, 30) + + // Verify org scoped quota limits + verifyQuota(ctx, t, owner, first.OrganizationID.String(), 0, 30) + verifyQuota(ctx, t, owner, second.ID.String(), 0, 15) }) } +func deprecatedQuotaEndpoint(ctx context.Context, client *codersdk.Client, userID string) (codersdk.WorkspaceQuota, error) { + res, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil) + if err != nil { + return codersdk.WorkspaceQuota{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return codersdk.WorkspaceQuota{}, codersdk.ReadBodyAsError(res) + } + var quota codersdk.WorkspaceQuota + return quota, json.NewDecoder(res.Body).Decode("a) +} + func planWithCost(cost int32) []*proto.Response { return []*proto.Response{{ Type: &proto.Response_Plan{