From 09f82b45376c9672daf9d9542d61e4fe21666e72 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 30 Jul 2024 11:00:55 +0100 Subject: [PATCH 01/16] DNM: example PR to show how to add a new RBAC resource --- coderd/apidoc/docs.go | 2 ++ coderd/apidoc/swagger.json | 2 ++ coderd/rbac/object_gen.go | 11 +++++++++++ coderd/rbac/policy/policy.go | 8 ++++++++ coderd/rbac/roles.go | 1 + coderd/rbac/roles_test.go | 14 ++++++++++++++ codersdk/rbacresources_gen.go | 2 ++ docs/api/members.md | 3 +++ docs/api/schemas.md | 1 + site/src/api/rbacresources_gen.ts | 6 ++++++ site/src/api/typesGenerated.ts | 2 ++ 11 files changed, 52 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b72db00912ff7..be302739e9760 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11212,6 +11212,7 @@ const docTemplate = `{ "deployment_config", "deployment_stats", "file", + "frobulator", "group", "license", "oauth2_app", @@ -11240,6 +11241,7 @@ const docTemplate = `{ "ResourceDeploymentConfig", "ResourceDeploymentStats", "ResourceFile", + "ResourceFrobulator", "ResourceGroup", "ResourceLicense", "ResourceOauth2App", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bf7216f1f313b..05406bebd0c6b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10114,6 +10114,7 @@ "deployment_config", "deployment_stats", "file", + "frobulator", "group", "license", "oauth2_app", @@ -10142,6 +10143,7 @@ "ResourceDeploymentConfig", "ResourceDeploymentStats", "ResourceFile", + "ResourceFrobulator", "ResourceGroup", "ResourceLicense", "ResourceOauth2App", diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index bc2846da49564..02f35c6e347d8 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -83,6 +83,16 @@ var ( Type: "file", } + // ResourceFrobulator + // Valid Actions + // - "ActionCreate" :: + // - "ActionDelete" :: + // - "ActionRead" :: + // - "ActionUpdate" :: + ResourceFrobulator = Object{ + Type: "frobulator", + } + // ResourceGroup // Valid Actions // - "ActionCreate" :: create a group @@ -270,6 +280,7 @@ func AllResources() []Objecter { ResourceDeploymentConfig, ResourceDeploymentStats, ResourceFile, + ResourceFrobulator, ResourceGroup, ResourceLicense, ResourceOauth2App, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 2390c9e30c785..31f87d5b12727 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -255,4 +255,12 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionDelete: actDef(""), }, }, + "frobulator": { + Actions: map[Action]ActionDefinition{ + ActionCreate: actDef(""), + ActionRead: actDef(""), + ActionUpdate: actDef(""), + ActionDelete: actDef(""), + }, + }, } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4511111feded6..e91c728eec43a 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -264,6 +264,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, + ResourceFrobulator.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, })...), Org: map[string][]Permission{}, User: []Permission{}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 225e5eb9d311e..88ff5536ea77a 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -630,6 +630,20 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + Name: "OnlyAdminsCanFrobulate", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceFrobulator, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: { + orgAdmin, otherOrgAdmin, orgMemberMe, + memberMe, userAdmin, templateAdmin, + orgAuditor, orgUserAdmin, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 573fea66b8c80..4c92521b5e3be 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -13,6 +13,7 @@ const ( ResourceDeploymentConfig RBACResource = "deployment_config" ResourceDeploymentStats RBACResource = "deployment_stats" ResourceFile RBACResource = "file" + ResourceFrobulator RBACResource = "frobulator" ResourceGroup RBACResource = "group" ResourceLicense RBACResource = "license" ResourceOauth2App RBACResource = "oauth2_app" @@ -62,6 +63,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceDeploymentConfig: {ActionRead, ActionUpdate}, ResourceDeploymentStats: {ActionRead}, ResourceFile: {ActionCreate, ActionRead}, + ResourceFrobulator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, diff --git a/docs/api/members.md b/docs/api/members.md index 1ecf490738f00..f9c7d4a746658 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -188,6 +188,7 @@ Status Code **200** | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | | `resource_type` | `file` | +| `resource_type` | `frobulator` | | `resource_type` | `group` | | `resource_type` | `license` | | `resource_type` | `oauth2_app` | @@ -311,6 +312,7 @@ Status Code **200** | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | | `resource_type` | `file` | +| `resource_type` | `frobulator` | | `resource_type` | `group` | | `resource_type` | `license` | | `resource_type` | `oauth2_app` | @@ -565,6 +567,7 @@ Status Code **200** | `resource_type` | `deployment_config` | | `resource_type` | `deployment_stats` | | `resource_type` | `file` | +| `resource_type` | `frobulator` | | `resource_type` | `group` | | `resource_type` | `license` | | `resource_type` | `oauth2_app` | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c1ec9979a0a13..11727fffa93a5 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4159,6 +4159,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `deployment_config` | | `deployment_stats` | | `file` | +| `frobulator` | | `group` | | `license` | | `oauth2_app` | diff --git a/site/src/api/rbacresources_gen.ts b/site/src/api/rbacresources_gen.ts index 37fe508fde89c..302c166ae85e2 100644 --- a/site/src/api/rbacresources_gen.ts +++ b/site/src/api/rbacresources_gen.ts @@ -44,6 +44,12 @@ export const RBACResourceActions: Partial< create: "create a file", read: "read files", }, + frobulator: { + create: "", + delete: "", + read: "", + update: "", + }, group: { create: "create a group", delete: "delete a group", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0ad30e1310cff..061a8a50a62a6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2257,6 +2257,7 @@ export type RBACResource = | "deployment_config" | "deployment_stats" | "file" + | "frobulator" | "group" | "license" | "oauth2_app" @@ -2284,6 +2285,7 @@ export const RBACResources: RBACResource[] = [ "deployment_config", "deployment_stats", "file", + "frobulator", "group", "license", "oauth2_app", From 7e862295aa7e0de898cf7875650c6177e2668ea0 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 30 Jul 2024 21:56:44 +0200 Subject: [PATCH 02/16] Database implementation Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 130 +++++++++++++++++ coderd/apidoc/swagger.json | 116 +++++++++++++++ coderd/coderd.go | 11 ++ coderd/database/dbauthz/dbauthz.go | 22 +++ coderd/database/dbauthz/dbauthz_test.go | 13 ++ coderd/database/dbmem/dbmem.go | 44 ++++++ coderd/database/dbmetrics/dbmetrics.go | 21 +++ coderd/database/dbmock/dbmock.go | 44 ++++++ coderd/database/dump.sql | 15 ++ coderd/database/foreign_key_constraint.go | 1 + .../migrations/000234_frobulations.down.sql | 1 + .../migrations/000234_frobulations.up.sql | 9 ++ coderd/database/models.go | 6 + coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 73 ++++++++++ coderd/database/queries/frobulators.sql | 12 ++ coderd/database/unique_constraint.go | 2 + coderd/frobulators.go | 109 ++++++++++++++ coderd/frobulators_test.go | 118 +++++++++++++++ coderd/rbac/roles.go | 1 - coderd/rbac/roles_test.go | 47 ++++-- codersdk/frobulators.go | 69 +++++++++ docs/api/frobulator.md | 135 ++++++++++++++++++ docs/api/schemas.md | 32 +++++ docs/manifest.json | 4 + site/src/api/typesGenerated.ts | 12 ++ 26 files changed, 1035 insertions(+), 15 deletions(-) create mode 100644 coderd/database/migrations/000234_frobulations.down.sql create mode 100644 coderd/database/migrations/000234_frobulations.up.sql create mode 100644 coderd/database/queries/frobulators.sql create mode 100644 coderd/frobulators.go create mode 100644 coderd/frobulators_test.go create mode 100644 codersdk/frobulators.go create mode 100644 docs/api/frobulator.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index be302739e9760..74fbae83e8965 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1033,6 +1033,112 @@ const docTemplate = `{ } } }, + "/frobulators": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Frobulator" + ], + "summary": "Get all frobulators", + "operationId": "get-all-frobulators", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Frobulator" + } + } + } + } + } + }, + "/frobulators/{user}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Frobulator" + ], + "summary": "Get user frobulators", + "operationId": "get-user-frobulators", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Frobulator" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Frobulator" + ], + "summary": "Post frobulator", + "operationId": "post-frobulator", + "parameters": [ + { + "description": "Insert Frobulator request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.InsertFrobulatorRequest" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "New frobulator ID" + } + } + } + }, "/groups/{group}": { "get": { "security": [ @@ -9839,6 +9945,22 @@ const docTemplate = `{ } } }, + "codersdk.Frobulator": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "model_number": { + "type": "string" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.GenerateAPIKeyResponse": { "type": "object", "properties": { @@ -9954,6 +10076,14 @@ const docTemplate = `{ } } }, + "codersdk.InsertFrobulatorRequest": { + "type": "object", + "properties": { + "model_number": { + "type": "string" + } + } + }, "codersdk.InsightsReportInterval": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 05406bebd0c6b..b58a3ef3844f6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -891,6 +891,98 @@ } } }, + "/frobulators": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Frobulator"], + "summary": "Get all frobulators", + "operationId": "get-all-frobulators", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Frobulator" + } + } + } + } + } + }, + "/frobulators/{user}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Frobulator"], + "summary": "Get user frobulators", + "operationId": "get-user-frobulators", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Frobulator" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Frobulator"], + "summary": "Post frobulator", + "operationId": "post-frobulator", + "parameters": [ + { + "description": "Insert Frobulator request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.InsertFrobulatorRequest" + } + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "New frobulator ID" + } + } + } + }, "/groups/{group}": { "get": { "security": [ @@ -8829,6 +8921,22 @@ } } }, + "codersdk.Frobulator": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "model_number": { + "type": "string" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.GenerateAPIKeyResponse": { "type": "object", "properties": { @@ -8938,6 +9046,14 @@ } } }, + "codersdk.InsertFrobulatorRequest": { + "type": "object", + "properties": { + "model_number": { + "type": "string" + } + } + }, "codersdk.InsightsReportInterval": { "type": "string", "enum": ["day", "week"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f8a59ad6efc6..1987a49055aaa 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1247,6 +1247,17 @@ func New(options *Options) *API { r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) }) + r.Route("/frobulators", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.listAllFrobulators) + r.Route("/{user}", func(r chi.Router) { + r.Use( + httpmw.ExtractUserParam(options.Database), + ) + r.Get("/", api.listUserFrobulators) + r.Post("/", api.createFrobulator) + }) + }) }) if options.SwaggerEndpoint { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d12b9aba23863..46a6153555115 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1204,6 +1204,13 @@ func (q *querier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, temp return q.db.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID) } +func (q *querier) GetAllFrobulators(ctx context.Context) ([]database.Frobulator, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator); err != nil { + return nil, err + } + return q.db.GetAllFrobulators(ctx) +} + func (q *querier) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil { return []database.TailnetAgent{}, err @@ -2052,6 +2059,13 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) { return q.db.GetUserCount(ctx) } +func (q *querier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]database.Frobulator, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(userID.String())); err != nil { + return nil, err + } + return q.db.GetUserFrobulators(ctx, userID) +} + func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { // Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms. if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate); err != nil { @@ -2537,6 +2551,14 @@ func (q *querier) InsertFile(ctx context.Context, arg database.InsertFileParams) return insert(q.log, q.auth, rbac.ResourceFile.WithOwner(arg.CreatedBy.String()), q.db.InsertFile)(ctx, arg) } +func (q *querier) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) error { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceFrobulator.WithOwner(arg.UserID.String())); err != nil { + return err + } + + return q.db.InsertFrobulator(ctx, arg) +} + func (q *querier) InsertGitSSHKey(ctx context.Context, arg database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { return insertWithAction(q.log, q.auth, rbac.ResourceUser.WithOwner(arg.UserID.String()).WithID(arg.UserID), policy.ActionUpdatePersonal, q.db.InsertGitSSHKey)(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0ec7d2b17fb9c..2f54e7b476b40 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2583,6 +2583,19 @@ func (s *MethodTestSuite) TestSystemFunctions() { Limit: 10, }).Asserts(rbac.ResourceSystem, policy.ActionRead) })) + s.Run("GetAllFrobulators", s.Subtest(func(db database.Store, check *expects) { + check.Args().Asserts(rbac.ResourceFrobulator, policy.ActionRead) + })) + s.Run("GetUserFrobulators", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(user.ID).Asserts(rbac.ResourceFrobulator.WithOwner(user.ID.String()), policy.ActionRead) + })) + s.Run("InsertFrobulator", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(database.InsertFrobulatorParams{ + UserID: user.ID, + }).Asserts(rbac.ResourceFrobulator.WithOwner(user.ID.String()), policy.ActionCreate) + })) } func (s *MethodTestSuite) TestOAuth2ProviderApps() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8d1088616f6bc..a3bceeec07b9c 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -62,6 +62,7 @@ func New() database.Store { groups: make([]database.Group, 0), groupMembers: make([]database.GroupMember, 0), auditLogs: make([]database.AuditLog, 0), + frobulators: make([]database.Frobulator, 0), files: make([]database.File, 0), gitSSHKey: make([]database.GitSSHKey, 0), notificationMessages: make([]database.NotificationMessage, 0), @@ -192,6 +193,7 @@ type data struct { workspaces []database.Workspace workspaceProxies []database.WorkspaceProxy customRoles []database.CustomRole + frobulators []database.Frobulator // Locks is a map of lock names. Any keys within the map are currently // locked. locks map[int64]struct{} @@ -2047,6 +2049,13 @@ func (q *FakeQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, return filteredBuilds, nil } +func (q *FakeQuerier) GetAllFrobulators(_ context.Context) ([]database.Frobulator, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + return q.frobulators, nil +} + func (*FakeQuerier) GetAllTailnetAgents(_ context.Context) ([]database.TailnetAgent, error) { return nil, ErrUnimplemented } @@ -4838,6 +4847,22 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) { return existing, nil } +func (q *FakeQuerier) GetUserFrobulators(_ context.Context, userID uuid.UUID) ([]database.Frobulator, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.Frobulator, 0, len(q.frobulators)) + for _, frob := range q.frobulators { + if frob.UserID != userID { + continue + } + + out = append(out, frob) + } + + return out, nil +} + func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { @@ -6217,6 +6242,25 @@ func (q *FakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParam return file, nil } +func (q *FakeQuerier) InsertFrobulator(_ context.Context, arg database.InsertFrobulatorParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + // nolint:gosimple // This is fine as it is. + q.frobulators = append(q.frobulators, database.Frobulator{ + ID: arg.ID, + UserID: arg.UserID, + ModelNumber: arg.ModelNumber, + }) + + return nil +} + func (q *FakeQuerier) InsertGitSSHKey(_ context.Context, arg database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { if err := validateDatabaseType(arg); err != nil { return database.GitSSHKey{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f987d0505653b..f1f8366d60391 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -459,6 +459,13 @@ func (m metricsStore) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, return r0, r1 } +func (m metricsStore) GetAllFrobulators(ctx context.Context) ([]database.Frobulator, error) { + start := time.Now() + r0, r1 := m.s.GetAllFrobulators(ctx) + m.queryLatencies.WithLabelValues("GetAllFrobulators").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) { start := time.Now() r0, r1 := m.s.GetAllTailnetAgents(ctx) @@ -1194,6 +1201,13 @@ func (m metricsStore) GetUserCount(ctx context.Context) (int64, error) { return count, err } +func (m metricsStore) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]database.Frobulator, error) { + start := time.Now() + r0, r1 := m.s.GetUserFrobulators(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserFrobulators").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { start := time.Now() r0, r1 := m.s.GetUserLatencyInsights(ctx, arg) @@ -1586,6 +1600,13 @@ func (m metricsStore) InsertFile(ctx context.Context, arg database.InsertFilePar return file, err } +func (m metricsStore) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) error { + start := time.Now() + r0 := m.s.InsertFrobulator(ctx, arg) + m.queryLatencies.WithLabelValues("InsertFrobulator").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) InsertGitSSHKey(ctx context.Context, arg database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { start := time.Now() key, err := m.s.InsertGitSSHKey(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 78cd95a69cde5..24cb39a068fe5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -820,6 +820,21 @@ func (mr *MockStoreMockRecorder) GetActiveWorkspaceBuildsByTemplateID(arg0, arg1 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveWorkspaceBuildsByTemplateID", reflect.TypeOf((*MockStore)(nil).GetActiveWorkspaceBuildsByTemplateID), arg0, arg1) } +// GetAllFrobulators mocks base method. +func (m *MockStore) GetAllFrobulators(arg0 context.Context) ([]database.Frobulator, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllFrobulators", arg0) + ret0, _ := ret[0].([]database.Frobulator) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllFrobulators indicates an expected call of GetAllFrobulators. +func (mr *MockStoreMockRecorder) GetAllFrobulators(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllFrobulators", reflect.TypeOf((*MockStore)(nil).GetAllFrobulators), arg0) +} + // GetAllTailnetAgents mocks base method. func (m *MockStore) GetAllTailnetAgents(arg0 context.Context) ([]database.TailnetAgent, error) { m.ctrl.T.Helper() @@ -2470,6 +2485,21 @@ func (mr *MockStoreMockRecorder) GetUserCount(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockStore)(nil).GetUserCount), arg0) } +// GetUserFrobulators mocks base method. +func (m *MockStore) GetUserFrobulators(arg0 context.Context, arg1 uuid.UUID) ([]database.Frobulator, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserFrobulators", arg0, arg1) + ret0, _ := ret[0].([]database.Frobulator) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserFrobulators indicates an expected call of GetUserFrobulators. +func (mr *MockStoreMockRecorder) GetUserFrobulators(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserFrobulators", reflect.TypeOf((*MockStore)(nil).GetUserFrobulators), arg0, arg1) +} + // GetUserLatencyInsights mocks base method. func (m *MockStore) GetUserLatencyInsights(arg0 context.Context, arg1 database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { m.ctrl.T.Helper() @@ -3321,6 +3351,20 @@ func (mr *MockStoreMockRecorder) InsertFile(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertFile", reflect.TypeOf((*MockStore)(nil).InsertFile), arg0, arg1) } +// InsertFrobulator mocks base method. +func (m *MockStore) InsertFrobulator(arg0 context.Context, arg1 database.InsertFrobulatorParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertFrobulator", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertFrobulator indicates an expected call of InsertFrobulator. +func (mr *MockStoreMockRecorder) InsertFrobulator(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertFrobulator", reflect.TypeOf((*MockStore)(nil).InsertFrobulator), arg0, arg1) +} + // InsertGitSSHKey mocks base method. func (m *MockStore) InsertGitSSHKey(arg0 context.Context, arg1 database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dc15cf9bd4af8..b25d6d65011d9 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -493,6 +493,12 @@ CREATE TABLE files ( id uuid DEFAULT gen_random_uuid() NOT NULL ); +CREATE TABLE frobulators ( + id uuid NOT NULL, + user_id uuid NOT NULL, + model_number text NOT NULL +); + CREATE TABLE gitsshkeys ( user_id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -1506,6 +1512,12 @@ ALTER TABLE ONLY files ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); +ALTER TABLE ONLY frobulators + ADD CONSTRAINT frobulators_model_number_key UNIQUE (model_number); + +ALTER TABLE ONLY frobulators + ADD CONSTRAINT frobulators_pkey PRIMARY KEY (id); + ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); @@ -1818,6 +1830,9 @@ CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links F ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY frobulators + ADD CONSTRAINT frobulators_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 6e6eef8862b72..e66047ce7e778 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,6 +7,7 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyFrobulatorsUserID ForeignKeyConstraint = "frobulators_user_id_fkey" // ALTER TABLE ONLY frobulators ADD CONSTRAINT frobulators_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitSSHKeysUserID ForeignKeyConstraint = "gitsshkeys_user_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); diff --git a/coderd/database/migrations/000234_frobulations.down.sql b/coderd/database/migrations/000234_frobulations.down.sql new file mode 100644 index 0000000000000..eaf995590b5f2 --- /dev/null +++ b/coderd/database/migrations/000234_frobulations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS frobulators; diff --git a/coderd/database/migrations/000234_frobulations.up.sql b/coderd/database/migrations/000234_frobulations.up.sql new file mode 100644 index 0000000000000..b5413a5bac4d7 --- /dev/null +++ b/coderd/database/migrations/000234_frobulations.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE frobulators +( + id uuid NOT NULL, + user_id uuid NOT NULL, + model_number TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (model_number), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 0ee78e286516e..90e9ea9634c9c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1973,6 +1973,12 @@ type File struct { ID uuid.UUID `db:"id" json:"id"` } +type Frobulator struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + ModelNumber string `db:"model_number" json:"model_number"` +} + type GitSSHKey struct { UserID uuid.UUID `db:"user_id" json:"user_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9d0494813e306..09c6b9653d0e5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -113,6 +113,7 @@ type sqlcQuerier interface { GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveUserCount(ctx context.Context) (int64, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) + GetAllFrobulators(ctx context.Context) ([]Frobulator, error) GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error) // For PG Coordinator HTMLDebug GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error) @@ -257,6 +258,7 @@ type sqlcQuerier interface { GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) + GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Frobulator, error) // GetUserLatencyInsights returns the median and 95th percentile connection // latency that users have experienced. The result can be filtered on // template_ids, meaning only user data from workspaces based on those templates @@ -334,6 +336,7 @@ type sqlcQuerier interface { InsertDeploymentID(ctx context.Context, value string) error InsertExternalAuthLink(ctx context.Context, arg InsertExternalAuthLinkParams) (ExternalAuthLink, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) + InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) error InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2e3a5c9892d40..f9245fa04a482 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1202,6 +1202,79 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } +const getAllFrobulators = `-- name: GetAllFrobulators :many +SELECT id, user_id, model_number +FROM frobulators +` + +func (q *sqlQuerier) GetAllFrobulators(ctx context.Context) ([]Frobulator, error) { + rows, err := q.db.QueryContext(ctx, getAllFrobulators) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Frobulator + for rows.Next() { + var i Frobulator + if err := rows.Scan(&i.ID, &i.UserID, &i.ModelNumber); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserFrobulators = `-- name: GetUserFrobulators :many +SELECT id, user_id, model_number +FROM frobulators +WHERE user_id = $1::uuid +` + +func (q *sqlQuerier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Frobulator, error) { + rows, err := q.db.QueryContext(ctx, getUserFrobulators, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Frobulator + for rows.Next() { + var i Frobulator + if err := rows.Scan(&i.ID, &i.UserID, &i.ModelNumber); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertFrobulator = `-- name: InsertFrobulator :exec +INSERT INTO frobulators (id, user_id, model_number) +VALUES ($1, $2, $3) +` + +type InsertFrobulatorParams struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + ModelNumber string `db:"model_number" json:"model_number"` +} + +func (q *sqlQuerier) InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) error { + _, err := q.db.ExecContext(ctx, insertFrobulator, arg.ID, arg.UserID, arg.ModelNumber) + return err +} + const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec DELETE FROM gitsshkeys diff --git a/coderd/database/queries/frobulators.sql b/coderd/database/queries/frobulators.sql new file mode 100644 index 0000000000000..1d1c3bd4255c9 --- /dev/null +++ b/coderd/database/queries/frobulators.sql @@ -0,0 +1,12 @@ +-- name: GetUserFrobulators :many +SELECT * +FROM frobulators +WHERE user_id = @user_id::uuid; + +-- name: GetAllFrobulators :many +SELECT * +FROM frobulators; + +-- name: InsertFrobulator :exec +INSERT INTO frobulators (id, user_id, model_number) +VALUES ($1, $2, $3); \ No newline at end of file diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index aecae02d572ff..097ebf9b47b23 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -15,6 +15,8 @@ const ( UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); + UniqueFrobulatorsModelNumberKey UniqueConstraint = "frobulators_model_number_key" // ALTER TABLE ONLY frobulators ADD CONSTRAINT frobulators_model_number_key UNIQUE (model_number); + UniqueFrobulatorsPkey UniqueConstraint = "frobulators_pkey" // ALTER TABLE ONLY frobulators ADD CONSTRAINT frobulators_pkey PRIMARY KEY (id); UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); UniqueGitSSHKeysPkey UniqueConstraint = "gitsshkeys_pkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); diff --git a/coderd/frobulators.go b/coderd/frobulators.go new file mode 100644 index 0000000000000..2292e61181eb9 --- /dev/null +++ b/coderd/frobulators.go @@ -0,0 +1,109 @@ +package coderd + +import ( + "net/http" + + "github.com/google/uuid" + + "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" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Post frobulator +// @ID post-frobulator +// @Security CoderSessionToken +// @Param request body codersdk.InsertFrobulatorRequest true "Insert Frobulator request" +// @Param user path string true "User ID, name, or me" +// @Accept json +// @Produce json +// @Tags Frobulator +// @Success 200 "New frobulator ID" +// @Router /frobulators/{user} [post] +func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceFrobulator.WithOwner(user.ID.String())) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.InsertFrobulatorRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + newID := uuid.New() + err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{ + ID: newID, + UserID: user.ID, + ModelNumber: req.ModelNumber, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, newID.String()) +} + +// @Summary Get user frobulators +// @ID get-user-frobulators +// @Security CoderSessionToken +// @Param user path string true "User ID, name, or me" +// @Produce json +// @Tags Frobulator +// @Success 200 {array} codersdk.Frobulator +// @Router /frobulators/{user} [get] +func (api *API) listUserFrobulators(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + key := httpmw.APIKey(r) + if !api.Authorize(r, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(key.UserID.String())) { + httpapi.Forbidden(rw) + return + } + + user := httpmw.UserParam(r) + frobs, err := api.Database.GetUserFrobulators(ctx, user.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + out := make([]codersdk.Frobulator, 0, len(frobs)) + for _, f := range frobs { + out = append(out, codersdk.Frobulator{ + ID: f.ID, + UserID: f.UserID, + ModelNumber: f.ModelNumber, + }) + } + + httpapi.Write(r.Context(), rw, http.StatusOK, out) +} + +// @Summary Get all frobulators +// @ID get-all-frobulators +// @Security CoderSessionToken +// @Produce json +// @Tags Frobulator +// @Success 200 {array} codersdk.Frobulator +// @Router /frobulators [get] +func (api *API) listAllFrobulators(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionRead, rbac.ResourceFrobulator) { + httpapi.Forbidden(rw) + return + } + + frobs, err := api.Database.GetAllFrobulators(ctx) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, frobs) +} diff --git a/coderd/frobulators_test.go b/coderd/frobulators_test.go new file mode 100644 index 0000000000000..709325d3dfe90 --- /dev/null +++ b/coderd/frobulators_test.go @@ -0,0 +1,118 @@ +package coderd_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestFrobulators(t *testing.T) { + t.Parallel() + + // Setup for all tests + api := coderdtest.New(t, nil) + + setupCtx := testutil.Context(t, testutil.WaitShort) + firstUser := coderdtest.CreateFirstUser(t, api) + + // Create 2 member, add one frobulator each + memberClient1, otherUser1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + memberClient2, otherUser2 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + frobulatorID, err := memberClient1.CreateFrobulator(setupCtx, otherUser1.ID, fmt.Sprintf("model-%s", uuid.NewString())) + require.NoError(t, err) + require.NotNil(t, frobulatorID) + frobulator2ID, err := memberClient2.CreateFrobulator(setupCtx, otherUser2.ID, fmt.Sprintf("model2-%s", uuid.NewString())) + require.NoError(t, err) + require.NotNil(t, frobulator2ID) + + t.Run("Read other members' frobulators", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: a new member + memberClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to view the frobulators of another user + frobs, err := memberClient.GetUserFrobulators(ctx, otherUser1.ID) + + // Then: validate that access was denied + require.Nil(t, frobs) + + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + // Unfortunately, the ExtractUserParam middleware returns a 400 Bad Request not a 403 Forbidden for unauthorized requests. + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + }) + + t.Run("Create and read own frobulators", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: a user which is not an admin + memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: attempting to create a frobulator + id, err := memberClient.CreateFrobulator(ctx, member.ID, fmt.Sprintf("model-%s", uuid.NewString())) + + // Then: it should succeed and should be queryable + require.NoError(t, err) + require.NotNil(t, id) + + frobs, err := memberClient.GetUserFrobulators(ctx, member.ID) + require.NoError(t, err) + require.Len(t, frobs, 1) + require.Equal(t, id, frobs[0].ID) + }) + + t.Run("Access all frobulators as admin", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: an owner - which has access to all other users' frobulators + adminClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID, rbac.RoleOwner()) + + // When: accessing all frobulators + frobs, err := adminClient.GetAllFrobulators(ctx) + + // Then: the expected number of frobulators are returned + require.NoError(t, err) + require.GreaterOrEqual(t, len(frobs), 2) + }) + + t.Run("Access specific users' frobulators as admin", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: an owner - which has access to all other users' frobulators + adminClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID, rbac.RoleOwner()) + + // When: accessing the frobulators of another user in their org + frobs, err := adminClient.GetUserFrobulators(ctx, otherUser1.ID) + + // Then: the expected frobulator should be returned + require.NoError(t, err) + require.Len(t, frobs, 1) + + var found bool + for _, f := range frobs { + if f.ID == frobulatorID { + found = true + } + } + require.True(t, found, "reference frobulator not found") + }) +} diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index e91c728eec43a..4511111feded6 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -264,7 +264,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop}, - ResourceFrobulator.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, })...), Org: map[string][]Permission{}, User: []Permission{}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 88ff5536ea77a..50e032e8580a9 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -590,6 +590,39 @@ func TestRolePermissions(t *testing.T) { false: {}, }, }, + { + // Users should be able to CRUD their own frobulators + // Admins from the current organization should be able to CRUD any other user's frobulators + // Owner should be able to CRUD any other user's frobulators + Name: "Frobulators", + Actions: []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceFrobulator.WithOwner(currentUser.String()).InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {orgMemberMe, orgAdmin, owner}, + false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + }, + }, + { + // Owner should be able to CRUD any other user's frobulators + Name: "FrobulatorsAnyUser", + Actions: []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceFrobulator.WithOwner(uuid.New().String()), // read frobulators of any user + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: {memberMe, orgMemberMe, orgAdmin, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + }, + }, + { + // Admins from the current organization should be able to CRUD any other user's frobulators + // Owner should be able to CRUD any other user's frobulators + Name: "FrobulatorsAnyUserInOrg", + Actions: []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceFrobulator.InOrg(orgID).WithOwner(uuid.New().String()), // read frobulators of any user + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin}, + false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + }, + }, // AnyOrganization tests { Name: "CreateOrgMember", @@ -630,20 +663,6 @@ func TestRolePermissions(t *testing.T) { }, }, }, - { - Name: "OnlyAdminsCanFrobulate", - Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - Resource: rbac.ResourceFrobulator, - AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner}, - false: { - orgAdmin, otherOrgAdmin, orgMemberMe, - memberMe, userAdmin, templateAdmin, - orgAuditor, orgUserAdmin, orgTemplateAdmin, - otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, - }, - }, - }, } // We expect every permission to be tested above. diff --git a/codersdk/frobulators.go b/codersdk/frobulators.go new file mode 100644 index 0000000000000..32a2c2f2a7239 --- /dev/null +++ b/codersdk/frobulators.go @@ -0,0 +1,69 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" +) + +type Frobulator struct { + ID uuid.UUID `json:"id" format:"uuid"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + ModelNumber string `json:"model_number"` +} + +type InsertFrobulatorRequest struct { + ModelNumber string `json:"model_number"` +} + +func (c *Client) CreateFrobulator(ctx context.Context, userID uuid.UUID, modelNumber string) (uuid.UUID, error) { + req := InsertFrobulatorRequest{ + ModelNumber: modelNumber, + } + + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/frobulators/%s", userID.String()), req) + if err != nil { + return uuid.Nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return uuid.Nil, ReadBodyAsError(res) + } + + var newID uuid.UUID + return newID, json.NewDecoder(res.Body).Decode(&newID) +} + +func (c *Client) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Frobulator, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/frobulators/%s", userID.String()), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var frobulators []Frobulator + return frobulators, json.NewDecoder(res.Body).Decode(&frobulators) +} + +func (c *Client) GetAllFrobulators(ctx context.Context) ([]Frobulator, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/frobulators", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var frobulators []Frobulator + return frobulators, json.NewDecoder(res.Body).Decode(&frobulators) +} diff --git a/docs/api/frobulator.md b/docs/api/frobulator.md new file mode 100644 index 0000000000000..31859aea7114c --- /dev/null +++ b/docs/api/frobulator.md @@ -0,0 +1,135 @@ +# Frobulator + +## Get all frobulators + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/frobulators \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /frobulators` + +### Example responses + +> 200 Response + +```json +[ + { + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "model_number": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Frobulator](schemas.md#codersdkfrobulator) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» id` | string(uuid) | false | | | +| `» model_number` | string | false | | | +| `» user_id` | string(uuid) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get user frobulators + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/frobulators/{user} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /frobulators/{user}` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------ | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +[ + { + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "model_number": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Frobulator](schemas.md#codersdkfrobulator) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» id` | string(uuid) | false | | | +| `» model_number` | string | false | | | +| `» user_id` | string(uuid) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Post frobulator + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/frobulators/{user} \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /frobulators/{user}` + +> Body parameter + +```json +{ + "model_number": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------------------------------------------------------------------------ | -------- | ------------------------- | +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.InsertFrobulatorRequest](schemas.md#codersdkinsertfrobulatorrequest) | true | Insert Frobulator request | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | New frobulator ID | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 11727fffa93a5..5e0ff9f327dbd 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2704,6 +2704,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | | | `limit` | integer | false | | | +## codersdk.Frobulator + +```json +{ + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "model_number": "string", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `id` | string | false | | | +| `model_number` | string | false | | | +| `user_id` | string | false | | | + ## codersdk.GenerateAPIKeyResponse ```json @@ -2867,6 +2885,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `refresh` | integer | false | | | | `threshold_database` | integer | false | | | +## codersdk.InsertFrobulatorRequest + +```json +{ + "model_number": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `model_number` | string | false | | | + ## codersdk.InsightsReportInterval ```json diff --git a/docs/manifest.json b/docs/manifest.json index 82dd73ada47c8..bac33a41de526 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -589,6 +589,10 @@ "title": "Files", "path": "./api/files.md" }, + { + "title": "Frobulator", + "path": "./api/frobulator.md" + }, { "title": "Git", "path": "./api/git.md" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 061a8a50a62a6..b9a62de573b35 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -586,6 +586,13 @@ export interface Feature { readonly actual?: number; } +// From codersdk/frobulators.go +export interface Frobulator { + readonly id: string; + readonly user_id: string; + readonly model_number: string; +} + // From codersdk/apikey.go export interface GenerateAPIKeyResponse { readonly key: string; @@ -630,6 +637,11 @@ export interface HealthcheckConfig { readonly threshold_database: number; } +// From codersdk/frobulators.go +export interface InsertFrobulatorRequest { + readonly model_number: string; +} + // From codersdk/workspaceagents.go export interface IssueReconnectingPTYSignedTokenRequest { readonly url: string; From d24bf8890eaee79ec3b7a71928b69cf5475ef839 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 31 Jul 2024 14:46:43 +0200 Subject: [PATCH 03/16] Add auditor role permission Signed-off-by: Danny Kopping --- coderd/rbac/roles.go | 3 ++- coderd/rbac/roles_test.go | 36 +++++++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4511111feded6..4dbf15d8bde47 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -439,7 +439,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ organizationID.String(): Permissions(map[string][]policy.Action{ - ResourceAuditLog.Type: {policy.ActionRead}, + ResourceAuditLog.Type: {policy.ActionRead}, + ResourceFrobulator.Type: {policy.ActionRead}, }), }, User: []Permission{}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 50e032e8580a9..a589dd2f5bfbb 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -591,17 +591,30 @@ func TestRolePermissions(t *testing.T) { }, }, { - // Users should be able to CRUD their own frobulators - // Admins from the current organization should be able to CRUD any other user's frobulators - // Owner should be able to CRUD any other user's frobulators - Name: "Frobulators", - Actions: []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + // Users should be able to modify their own frobulators + // Admins from the current organization should be able to modify any other user's frobulators + // Owner should be able to modify any other user's frobulators + Name: "FrobulatorsModify", + Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, Resource: rbac.ResourceFrobulator.WithOwner(currentUser.String()).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {orgMemberMe, orgAdmin, owner}, false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, + { + // Users should be able to read their own frobulators + // Admins from the current organization should be able to read any other user's frobulators + // Auditors should be able to read any other user's frobulators + // Owner should be able to read any other user's frobulators + Name: "FrobulatorsReadOnly", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceFrobulator.WithOwner(currentUser.String()).InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {orgMemberMe, orgAdmin, owner, orgAuditor}, + false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin}, + }, + }, { // Owner should be able to CRUD any other user's frobulators Name: "FrobulatorsAnyUser", @@ -613,14 +626,15 @@ func TestRolePermissions(t *testing.T) { }, }, { - // Admins from the current organization should be able to CRUD any other user's frobulators - // Owner should be able to CRUD any other user's frobulators - Name: "FrobulatorsAnyUserInOrg", - Actions: []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + // Admins from the current organization should be able to read any other user's frobulators + // Auditors should be able to read any other user's frobulators + // Owner should be able to read any other user's frobulators + Name: "FrobulatorsReadAnyUserInOrg", + Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceFrobulator.InOrg(orgID).WithOwner(uuid.New().String()), // read frobulators of any user AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, orgAdmin}, - false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + true: {owner, orgAdmin, orgAuditor}, + false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin}, }, }, // AnyOrganization tests From 16d0869028fcf51f15c2c0eb327f59bb7beaa554 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 1 Aug 2024 15:11:09 +0100 Subject: [PATCH 04/16] frobulating happily together --- coderd/database/dbauthz/dbauthz.go | 14 ++++++++++---- coderd/database/modelmethods.go | 4 ++++ coderd/frobulators.go | 16 ---------------- coderd/rbac/roles.go | 5 +++-- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 46a6153555115..aaefb50cb1158 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2060,10 +2060,16 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) { } func (q *querier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]database.Frobulator, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(userID.String())); err != nil { - return nil, err - } - return q.db.GetUserFrobulators(ctx, userID) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetUserFrobulators)(ctx, userID) + // Alternatively: just check if you can read *a* Frobulator owned by your ID. + // This is technically incorrect, as if Frobulators later become org-scoped, this will no longer be correct! + // But it's **much, much faster** . + /* + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(userID.String())); err != nil { + return nil, err + } + return q.db.GetUserFrobulators(ctx, userID) + */ } func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d92a6048baf22..03e75e545d400 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -117,6 +117,10 @@ func (k APIKey) RBACObject() rbac.Object { WithOwner(k.UserID.String()) } +func (f Frobulator) RBACObject() rbac.Object { + return rbac.ResourceFrobulator.WithID(f.ID).WithOwner(f.UserID.String()) +} + func (t Template) RBACObject() rbac.Object { return rbac.ResourceTemplate.WithID(t.ID). InOrg(t.OrganizationID). diff --git a/coderd/frobulators.go b/coderd/frobulators.go index 2292e61181eb9..ba8b203f8d956 100644 --- a/coderd/frobulators.go +++ b/coderd/frobulators.go @@ -8,8 +8,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" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -26,10 +24,6 @@ import ( func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) - if !api.Authorize(r, policy.ActionCreate, rbac.ResourceFrobulator.WithOwner(user.ID.String())) { - httpapi.Forbidden(rw) - return - } var req codersdk.InsertFrobulatorRequest if !httpapi.Read(ctx, rw, r, &req) { @@ -60,12 +54,6 @@ func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { // @Router /frobulators/{user} [get] func (api *API) listUserFrobulators(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - key := httpmw.APIKey(r) - if !api.Authorize(r, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(key.UserID.String())) { - httpapi.Forbidden(rw) - return - } - user := httpmw.UserParam(r) frobs, err := api.Database.GetUserFrobulators(ctx, user.ID) if err != nil { @@ -94,10 +82,6 @@ func (api *API) listUserFrobulators(rw http.ResponseWriter, r *http.Request) { // @Router /frobulators [get] func (api *API) listAllFrobulators(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionRead, rbac.ResourceFrobulator) { - httpapi.Forbidden(rw) - return - } frobs, err := api.Database.GetAllFrobulators(ctx) if err != nil { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 4dbf15d8bde47..36b250ef7db7a 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -310,6 +310,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceDeploymentConfig.Type: {policy.ActionRead}, // Org roles are not really used yet, so grant the perm at the site level. ResourceOrganizationMember.Type: {policy.ActionRead}, + // The site-wide auditor is allowed to read *all* frobulators, regardless of who owns them. + ResourceFrobulator.Type: {policy.ActionRead}, }), Org: map[string][]Permission{}, User: []Permission{}, @@ -439,8 +441,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Site: []Permission{}, Org: map[string][]Permission{ organizationID.String(): Permissions(map[string][]policy.Action{ - ResourceAuditLog.Type: {policy.ActionRead}, - ResourceFrobulator.Type: {policy.ActionRead}, + ResourceAuditLog.Type: {policy.ActionRead}, }), }, User: []Permission{}, From cf9df67f5e244f55780160e51c358b6b16a6380c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 5 Sep 2024 11:26:30 +0200 Subject: [PATCH 05/16] WIP: org-scope for frobulators Signed-off-by: Danny Kopping --- coderd/coderd.go | 22 +- coderd/database/dbauthz/dbauthz.go | 40 ++-- coderd/database/dbauthz/dbauthz_test.go | 34 ++- coderd/database/dbgen/dbgen.go | 10 + coderd/database/dbmem/dbmem.go | 72 +++--- coderd/database/dbmetrics/dbmetrics.go | 30 +-- coderd/database/dbmock/dbmock.go | 66 +++--- coderd/database/dump.sql | 4 + coderd/database/foreign_key_constraint.go | 1 + .../migrations/000245_frobulations.up.sql | 4 +- coderd/database/modelmethods.go | 11 +- coderd/database/models.go | 1 + coderd/database/querier.go | 6 +- coderd/database/queries.sql.go | 80 +++---- coderd/database/queries/frobulators.sql | 17 +- coderd/frobulators.go | 59 +++-- coderd/frobulators_test.go | 207 ++++++++++++++---- coderd/rbac/object_gen.go | 8 +- coderd/rbac/policy/policy.go | 8 +- codersdk/frobulators.go | 20 +- site/src/api/rbacresourcesGenerated.ts | 8 +- site/src/api/typesGenerated.ts | 17 +- 22 files changed, 469 insertions(+), 256 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 1471fe97c400c..0c51057222d46 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -909,6 +909,17 @@ func New(options *Options) *API { }) }) }) + r.Route("/frobulators", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/{user}", func(r chi.Router) { + r.Use( + httpmw.ExtractUserParam(options.Database), + ) + r.Get("/", api.listFrobulators) + r.Post("/", api.createFrobulator) + r.Delete("/{id}", api.deleteFrobulator) + }) + }) }) }) r.Route("/templates", func(r chi.Router) { @@ -1257,17 +1268,6 @@ func New(options *Options) *API { }) r.Get("/dispatch-methods", api.notificationDispatchMethods) }) - r.Route("/frobulators", func(r chi.Router) { - r.Use(apiKeyMiddleware) - r.Get("/", api.listAllFrobulators) - r.Route("/{user}", func(r chi.Router) { - r.Use( - httpmw.ExtractUserParam(options.Database), - ) - r.Get("/", api.listUserFrobulators) - r.Post("/", api.createFrobulator) - }) - }) }) if options.SwaggerEndpoint { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f0053131fb9c4..921d5de124729 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1059,6 +1059,13 @@ func (q *querier) DeleteExternalAuthLink(ctx context.Context, arg database.Delet }, q.db.DeleteExternalAuthLink)(ctx, arg) } +func (q *querier) DeleteFrobulator(ctx context.Context, args database.DeleteFrobulatorParams) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceFrobulator.WithID(args.ID).WithOwner(args.UserID.String()).InOrg(args.OrgID)); err != nil { + return err + } + return q.db.DeleteFrobulator(ctx, args) +} + func (q *querier) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, q.db.GetGitSSHKey, q.db.DeleteGitSSHKey)(ctx, userID) } @@ -1298,13 +1305,6 @@ func (q *querier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, temp return q.db.GetActiveWorkspaceBuildsByTemplateID(ctx, templateID) } -func (q *querier) GetAllFrobulators(ctx context.Context) ([]database.Frobulator, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator); err != nil { - return nil, err - } - return q.db.GetAllFrobulators(ctx) -} - func (q *querier) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTailnetCoordinator); err != nil { return []database.TailnetAgent{}, err @@ -1464,6 +1464,13 @@ func (q *querier) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]dat return q.db.GetFileTemplates(ctx, fileID) } +func (q *querier) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(arg.UserID.String()).InOrg(arg.OrgID)); err != nil { + return nil, err + } + return q.db.GetFrobulators(ctx, arg) +} + func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetGitSSHKey)(ctx, userID) } @@ -2172,19 +2179,6 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) { return q.db.GetUserCount(ctx) } -func (q *querier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]database.Frobulator, error) { - return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetUserFrobulators)(ctx, userID) - // Alternatively: just check if you can read *a* Frobulator owned by your ID. - // This is technically incorrect, as if Frobulators later become org-scoped, this will no longer be correct! - // But it's **much, much faster** . - /* - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(userID.String())); err != nil { - return nil, err - } - return q.db.GetUserFrobulators(ctx, userID) - */ -} - func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { // Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms. if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate); err != nil { @@ -2705,9 +2699,9 @@ func (q *querier) InsertFile(ctx context.Context, arg database.InsertFileParams) return insert(q.log, q.auth, rbac.ResourceFile.WithOwner(arg.CreatedBy.String()), q.db.InsertFile)(ctx, arg) } -func (q *querier) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) error { - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceFrobulator.WithOwner(arg.UserID.String())); err != nil { - return err +func (q *querier) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) (database.Frobulator, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceFrobulator.WithOwner(arg.UserID.String()).InOrg(arg.OrgID)); err != nil { + return database.Frobulator{}, err } return q.db.InsertFrobulator(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 153a5c9343576..ad758ccdd487b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2751,18 +2751,40 @@ func (s *MethodTestSuite) TestNotifications() { } func (s *MethodTestSuite) TestFrobulators() { - s.Run("GetAllFrobulators", s.Subtest(func(db database.Store, check *expects) { - check.Args().Asserts(rbac.ResourceFrobulator, policy.ActionRead) - })) - s.Run("GetUserFrobulators", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetFrobulators", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) - check.Args(user.ID).Asserts(rbac.ResourceFrobulator.WithOwner(user.ID.String()), policy.ActionRead) + org := dbgen.Organization(s.T(), db, database.Organization{}) + check.Args(database.GetFrobulatorsParams{ + UserID: user.ID, + OrgID: org.ID, + }).Asserts( + rbac.ResourceFrobulator. + WithOwner(user.ID.String()). + InOrg(org.ID), + policy.ActionRead, + ) })) s.Run("InsertFrobulator", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) + org := dbgen.Organization(s.T(), db, database.Organization{}) check.Args(database.InsertFrobulatorParams{ UserID: user.ID, - }).Asserts(rbac.ResourceFrobulator.WithOwner(user.ID.String()), policy.ActionCreate) + OrgID: org.ID, + }).Asserts(rbac.ResourceFrobulator.WithOwner(user.ID.String()).InOrg(org.ID), policy.ActionCreate) + })) + s.Run("DeleteFrobulator", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + org := dbgen.Organization(s.T(), db, database.Organization{}) + frob := dbgen.Frobulator(s.T(), db, database.Frobulator{ + UserID: user.ID, + OrgID: org.ID, + ModelNumber: "warhead-1", + }) + check.Args(database.DeleteFrobulatorParams{ + ID: frob.ID, + UserID: frob.UserID, + OrgID: frob.OrgID, + }).Asserts(rbac.ResourceFrobulator.WithOwner(frob.UserID.String()).InOrg(frob.OrgID).WithID(frob.ID), policy.ActionDelete) })) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index ccacb0dc0a995..49a5329d23e9b 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -892,6 +892,16 @@ func CustomRole(t testing.TB, db database.Store, seed database.CustomRole) datab return role } +func Frobulator(t testing.TB, db database.Store, orig database.Frobulator) database.Frobulator { + frob, err := db.InsertFrobulator(genCtx, database.InsertFrobulatorParams{ + UserID: orig.UserID, + OrgID: orig.OrgID, + ModelNumber: orig.ModelNumber, + }) + require.NoError(t, err, "insert frobulator") + return frob +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index a3d59dbeeae2b..b07d89d2ae262 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1485,6 +1485,20 @@ func (q *FakeQuerier) DeleteExternalAuthLink(_ context.Context, arg database.Del return sql.ErrNoRows } +func (q *FakeQuerier) DeleteFrobulator(_ context.Context, args database.DeleteFrobulatorParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, frob := range q.frobulators { + if frob.ID == args.ID && frob.UserID == args.UserID && frob.OrgID == args.OrgID { + q.frobulators = append(q.frobulators[:i], q.frobulators[i+1:]...) + return nil + } + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -2130,13 +2144,6 @@ func (q *FakeQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, return filteredBuilds, nil } -func (q *FakeQuerier) GetAllFrobulators(_ context.Context) ([]database.Frobulator, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - return q.frobulators, nil -} - func (*FakeQuerier) GetAllTailnetAgents(_ context.Context) ([]database.TailnetAgent, error) { return nil, ErrUnimplemented } @@ -2508,6 +2515,27 @@ func (q *FakeQuerier) GetFileTemplates(_ context.Context, id uuid.UUID) ([]datab return rows, nil } +func (q *FakeQuerier) GetFrobulators(_ context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.Frobulator, 0, len(q.frobulators)) + for _, frob := range q.frobulators { + if frob.UserID != arg.UserID || frob.OrgID != arg.OrgID { + continue + } + + out = append(out, frob) + } + + return out, nil +} + func (q *FakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4865,22 +4893,6 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) { return existing, nil } -func (q *FakeQuerier) GetUserFrobulators(_ context.Context, userID uuid.UUID) ([]database.Frobulator, error) { - q.mutex.RLock() - defer q.mutex.RUnlock() - - out := make([]database.Frobulator, 0, len(q.frobulators)) - for _, frob := range q.frobulators { - if frob.UserID != userID { - continue - } - - out = append(out, frob) - } - - return out, nil -} - func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { @@ -6307,23 +6319,25 @@ func (q *FakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParam return file, nil } -func (q *FakeQuerier) InsertFrobulator(_ context.Context, arg database.InsertFrobulatorParams) error { +func (q *FakeQuerier) InsertFrobulator(_ context.Context, arg database.InsertFrobulatorParams) (database.Frobulator, error) { err := validateDatabaseType(arg) if err != nil { - return err + return database.Frobulator{}, err } q.mutex.Lock() defer q.mutex.Unlock() // nolint:gosimple // This is fine as it is. - q.frobulators = append(q.frobulators, database.Frobulator{ - ID: arg.ID, + frob := database.Frobulator{ + ID: uuid.New(), UserID: arg.UserID, + OrgID: arg.OrgID, ModelNumber: arg.ModelNumber, - }) + } + q.frobulators = append(q.frobulators, frob) - return nil + return frob, nil } func (q *FakeQuerier) InsertGitSSHKey(_ context.Context, arg database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index b6446b578a2e8..1af97e5c4a80b 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -228,6 +228,13 @@ func (m metricsStore) DeleteExternalAuthLink(ctx context.Context, arg database.D return r0 } +func (m metricsStore) DeleteFrobulator(ctx context.Context, id database.DeleteFrobulatorParams) error { + start := time.Now() + r0 := m.s.DeleteFrobulator(ctx, id) + m.queryLatencies.WithLabelValues("DeleteFrobulator").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { start := time.Now() err := m.s.DeleteGitSSHKey(ctx, userID) @@ -466,13 +473,6 @@ func (m metricsStore) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, return r0, r1 } -func (m metricsStore) GetAllFrobulators(ctx context.Context) ([]database.Frobulator, error) { - start := time.Now() - r0, r1 := m.s.GetAllFrobulators(ctx) - m.queryLatencies.WithLabelValues("GetAllFrobulators").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m metricsStore) GetAllTailnetAgents(ctx context.Context) ([]database.TailnetAgent, error) { start := time.Now() r0, r1 := m.s.GetAllTailnetAgents(ctx) @@ -627,6 +627,13 @@ func (m metricsStore) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([ return rows, err } +func (m metricsStore) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) { + start := time.Now() + r0, r1 := m.s.GetFrobulators(ctx, arg) + m.queryLatencies.WithLabelValues("GetFrobulators").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { start := time.Now() key, err := m.s.GetGitSSHKey(ctx, userID) @@ -1215,13 +1222,6 @@ func (m metricsStore) GetUserCount(ctx context.Context) (int64, error) { return count, err } -func (m metricsStore) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]database.Frobulator, error) { - start := time.Now() - r0, r1 := m.s.GetUserFrobulators(ctx, userID) - m.queryLatencies.WithLabelValues("GetUserFrobulators").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m metricsStore) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { start := time.Now() r0, r1 := m.s.GetUserLatencyInsights(ctx, arg) @@ -1628,7 +1628,7 @@ func (m metricsStore) InsertFile(ctx context.Context, arg database.InsertFilePar return file, err } -func (m metricsStore) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) error { +func (m metricsStore) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) (database.Frobulator, error) { start := time.Now() r0 := m.s.InsertFrobulator(ctx, arg) m.queryLatencies.WithLabelValues("InsertFrobulator").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9dad8133ab352..80cdd8dd6401d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -345,6 +345,20 @@ func (mr *MockStoreMockRecorder) DeleteExternalAuthLink(arg0, arg1 any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExternalAuthLink", reflect.TypeOf((*MockStore)(nil).DeleteExternalAuthLink), arg0, arg1) } +// DeleteFrobulator mocks base method. +func (m *MockStore) DeleteFrobulator(arg0 context.Context, arg1 database.DeleteFrobulatorParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteFrobulator", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteFrobulator indicates an expected call of DeleteFrobulator. +func (mr *MockStoreMockRecorder) DeleteFrobulator(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFrobulator", reflect.TypeOf((*MockStore)(nil).DeleteFrobulator), arg0, arg1) +} + // DeleteGitSSHKey mocks base method. func (m *MockStore) DeleteGitSSHKey(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() @@ -834,21 +848,6 @@ func (mr *MockStoreMockRecorder) GetActiveWorkspaceBuildsByTemplateID(arg0, arg1 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveWorkspaceBuildsByTemplateID", reflect.TypeOf((*MockStore)(nil).GetActiveWorkspaceBuildsByTemplateID), arg0, arg1) } -// GetAllFrobulators mocks base method. -func (m *MockStore) GetAllFrobulators(arg0 context.Context) ([]database.Frobulator, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllFrobulators", arg0) - ret0, _ := ret[0].([]database.Frobulator) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllFrobulators indicates an expected call of GetAllFrobulators. -func (mr *MockStoreMockRecorder) GetAllFrobulators(arg0 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllFrobulators", reflect.TypeOf((*MockStore)(nil).GetAllFrobulators), arg0) -} - // GetAllTailnetAgents mocks base method. func (m *MockStore) GetAllTailnetAgents(arg0 context.Context) ([]database.TailnetAgent, error) { m.ctrl.T.Helper() @@ -1239,6 +1238,21 @@ func (mr *MockStoreMockRecorder) GetFileTemplates(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileTemplates", reflect.TypeOf((*MockStore)(nil).GetFileTemplates), arg0, arg1) } +// GetFrobulators mocks base method. +func (m *MockStore) GetFrobulators(arg0 context.Context, arg1 database.GetFrobulatorsParams) ([]database.Frobulator, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFrobulators", arg0, arg1) + ret0, _ := ret[0].([]database.Frobulator) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFrobulators indicates an expected call of GetFrobulators. +func (mr *MockStoreMockRecorder) GetFrobulators(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFrobulators", reflect.TypeOf((*MockStore)(nil).GetFrobulators), arg0, arg1) +} + // GetGitSSHKey mocks base method. func (m *MockStore) GetGitSSHKey(arg0 context.Context, arg1 uuid.UUID) (database.GitSSHKey, error) { m.ctrl.T.Helper() @@ -2529,21 +2543,6 @@ func (mr *MockStoreMockRecorder) GetUserCount(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockStore)(nil).GetUserCount), arg0) } -// GetUserFrobulators mocks base method. -func (m *MockStore) GetUserFrobulators(arg0 context.Context, arg1 uuid.UUID) ([]database.Frobulator, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserFrobulators", arg0, arg1) - ret0, _ := ret[0].([]database.Frobulator) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetUserFrobulators indicates an expected call of GetUserFrobulators. -func (mr *MockStoreMockRecorder) GetUserFrobulators(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserFrobulators", reflect.TypeOf((*MockStore)(nil).GetUserFrobulators), arg0, arg1) -} - // GetUserLatencyInsights mocks base method. func (m *MockStore) GetUserLatencyInsights(arg0 context.Context, arg1 database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { m.ctrl.T.Helper() @@ -3426,11 +3425,12 @@ func (mr *MockStoreMockRecorder) InsertFile(arg0, arg1 any) *gomock.Call { } // InsertFrobulator mocks base method. -func (m *MockStore) InsertFrobulator(arg0 context.Context, arg1 database.InsertFrobulatorParams) error { +func (m *MockStore) InsertFrobulator(arg0 context.Context, arg1 database.InsertFrobulatorParams) (database.Frobulator, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertFrobulator", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(database.Frobulator) + ret1, _ := ret[1].(error) + return ret0, ret1 } // InsertFrobulator indicates an expected call of InsertFrobulator. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 1fc4668bd88fa..51b3185f40569 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -539,6 +539,7 @@ CREATE TABLE files ( CREATE TABLE frobulators ( id uuid NOT NULL, user_id uuid NOT NULL, + org_id uuid NOT NULL, model_number text NOT NULL ); @@ -1933,6 +1934,9 @@ CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links F ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY frobulators + ADD CONSTRAINT frobulators_org_id_fkey FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY frobulators ADD CONSTRAINT frobulators_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 4b8231fe22ca2..e4abb523b5114 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,6 +7,7 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyFrobulatorsOrgID ForeignKeyConstraint = "frobulators_org_id_fkey" // ALTER TABLE ONLY frobulators ADD CONSTRAINT frobulators_org_id_fkey FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyFrobulatorsUserID ForeignKeyConstraint = "frobulators_user_id_fkey" // ALTER TABLE ONLY frobulators ADD CONSTRAINT frobulators_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); diff --git a/coderd/database/migrations/000245_frobulations.up.sql b/coderd/database/migrations/000245_frobulations.up.sql index b5413a5bac4d7..b4a1b90f54ff7 100644 --- a/coderd/database/migrations/000245_frobulations.up.sql +++ b/coderd/database/migrations/000245_frobulations.up.sql @@ -2,8 +2,10 @@ CREATE TABLE frobulators ( id uuid NOT NULL, user_id uuid NOT NULL, + org_id uuid NOT NULL, model_number TEXT NOT NULL, PRIMARY KEY (id), UNIQUE (model_number), - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (org_id) REFERENCES organizations (id) ON DELETE CASCADE ); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 365a9db21eacf..52a9312cb102a 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -132,10 +132,6 @@ func (k APIKey) RBACObject() rbac.Object { WithOwner(k.UserID.String()) } -func (f Frobulator) RBACObject() rbac.Object { - return rbac.ResourceFrobulator.WithID(f.ID).WithOwner(f.UserID.String()) -} - func (t Template) RBACObject() rbac.Object { return rbac.ResourceTemplate.WithID(t.ID). InOrg(t.OrganizationID). @@ -305,6 +301,13 @@ func (a GetOAuth2ProviderAppsByUserIDRow) RBACObject() rbac.Object { return a.OAuth2ProviderApp.RBACObject() } +func (f Frobulator) RBACObject() rbac.Object { + return rbac.ResourceFrobulator. + WithID(f.ID). + WithOwner(f.UserID.String()). + InOrg(f.OrgID) +} + type WorkspaceAgentConnectionStatus struct { Status WorkspaceAgentStatus `json:"status"` FirstConnectedAt *time.Time `json:"first_connected_at"` diff --git a/coderd/database/models.go b/coderd/database/models.go index 1c7a716bef5c9..22bb553a9de05 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2037,6 +2037,7 @@ type File struct { type Frobulator struct { ID uuid.UUID `db:"id" json:"id"` UserID uuid.UUID `db:"user_id" json:"user_id"` + OrgID uuid.UUID `db:"org_id" json:"org_id"` ModelNumber string `db:"model_number" json:"model_number"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e666ab3d9a29b..a92b54a7c7d2e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -71,6 +71,7 @@ type sqlcQuerier interface { DeleteCoordinator(ctx context.Context, id uuid.UUID) error DeleteCustomRole(ctx context.Context, arg DeleteCustomRoleParams) error DeleteExternalAuthLink(ctx context.Context, arg DeleteExternalAuthLinkParams) error + DeleteFrobulator(ctx context.Context, arg DeleteFrobulatorParams) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error @@ -114,7 +115,6 @@ type sqlcQuerier interface { GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveUserCount(ctx context.Context) (int64, error) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error) - GetAllFrobulators(ctx context.Context) ([]Frobulator, error) GetAllTailnetAgents(ctx context.Context) ([]TailnetAgent, error) // For PG Coordinator HTMLDebug GetAllTailnetCoordinators(ctx context.Context) ([]TailnetCoordinator, error) @@ -143,6 +143,7 @@ type sqlcQuerier interface { GetFileByID(ctx context.Context, id uuid.UUID) (File, error) // Get all templates that use a file. GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error) + GetFrobulators(ctx context.Context, arg GetFrobulatorsParams) ([]Frobulator, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) @@ -261,7 +262,6 @@ type sqlcQuerier interface { GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) - GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Frobulator, error) // GetUserLatencyInsights returns the median and 95th percentile connection // latency that users have experienced. The result can be filtered on // template_ids, meaning only user data from workspaces based on those templates @@ -341,7 +341,7 @@ type sqlcQuerier interface { InsertDeploymentID(ctx context.Context, value string) error InsertExternalAuthLink(ctx context.Context, arg InsertExternalAuthLinkParams) (ExternalAuthLink, error) InsertFile(ctx context.Context, arg InsertFileParams) (File, error) - InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) error + InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) (Frobulator, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ac3c317e2f6ee..6a5526e08f423 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1191,42 +1191,35 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } -const getAllFrobulators = `-- name: GetAllFrobulators :many -SELECT id, user_id, model_number -FROM frobulators +const deleteFrobulator = `-- name: DeleteFrobulator :exec +DELETE FROM frobulators +WHERE id = $1 AND user_id = $2 AND org_id = $3 ` -func (q *sqlQuerier) GetAllFrobulators(ctx context.Context) ([]Frobulator, error) { - rows, err := q.db.QueryContext(ctx, getAllFrobulators) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Frobulator - for rows.Next() { - var i Frobulator - if err := rows.Scan(&i.ID, &i.UserID, &i.ModelNumber); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +type DeleteFrobulatorParams struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrgID uuid.UUID `db:"org_id" json:"org_id"` } -const getUserFrobulators = `-- name: GetUserFrobulators :many -SELECT id, user_id, model_number +func (q *sqlQuerier) DeleteFrobulator(ctx context.Context, arg DeleteFrobulatorParams) error { + _, err := q.db.ExecContext(ctx, deleteFrobulator, arg.ID, arg.UserID, arg.OrgID) + return err +} + +const getFrobulators = `-- name: GetFrobulators :many +SELECT id, user_id, org_id, model_number FROM frobulators -WHERE user_id = $1::uuid +WHERE user_id = $1 AND org_id = $2 ` -func (q *sqlQuerier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Frobulator, error) { - rows, err := q.db.QueryContext(ctx, getUserFrobulators, userID) +type GetFrobulatorsParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrgID uuid.UUID `db:"org_id" json:"org_id"` +} + +func (q *sqlQuerier) GetFrobulators(ctx context.Context, arg GetFrobulatorsParams) ([]Frobulator, error) { + rows, err := q.db.QueryContext(ctx, getFrobulators, arg.UserID, arg.OrgID) if err != nil { return nil, err } @@ -1234,7 +1227,12 @@ func (q *sqlQuerier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ( var items []Frobulator for rows.Next() { var i Frobulator - if err := rows.Scan(&i.ID, &i.UserID, &i.ModelNumber); err != nil { + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.OrgID, + &i.ModelNumber, + ); err != nil { return nil, err } items = append(items, i) @@ -1248,20 +1246,28 @@ func (q *sqlQuerier) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ( return items, nil } -const insertFrobulator = `-- name: InsertFrobulator :exec -INSERT INTO frobulators (id, user_id, model_number) -VALUES ($1, $2, $3) +const insertFrobulator = `-- name: InsertFrobulator :one +INSERT INTO frobulators (id, user_id, org_id, model_number) +VALUES (gen_random_uuid(), $1, $2, $3) +RETURNING id, user_id, org_id, model_number ` type InsertFrobulatorParams struct { - ID uuid.UUID `db:"id" json:"id"` UserID uuid.UUID `db:"user_id" json:"user_id"` + OrgID uuid.UUID `db:"org_id" json:"org_id"` ModelNumber string `db:"model_number" json:"model_number"` } -func (q *sqlQuerier) InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) error { - _, err := q.db.ExecContext(ctx, insertFrobulator, arg.ID, arg.UserID, arg.ModelNumber) - return err +func (q *sqlQuerier) InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) (Frobulator, error) { + row := q.db.QueryRowContext(ctx, insertFrobulator, arg.UserID, arg.OrgID, arg.ModelNumber) + var i Frobulator + err := row.Scan( + &i.ID, + &i.UserID, + &i.OrgID, + &i.ModelNumber, + ) + return i, err } const deleteGitSSHKey = `-- name: DeleteGitSSHKey :exec diff --git a/coderd/database/queries/frobulators.sql b/coderd/database/queries/frobulators.sql index 1d1c3bd4255c9..fd7362e2a3f93 100644 --- a/coderd/database/queries/frobulators.sql +++ b/coderd/database/queries/frobulators.sql @@ -1,12 +1,13 @@ --- name: GetUserFrobulators :many +-- name: GetFrobulators :many SELECT * FROM frobulators -WHERE user_id = @user_id::uuid; +WHERE user_id = $1 AND org_id = $2; --- name: GetAllFrobulators :many -SELECT * -FROM frobulators; +-- name: InsertFrobulator :one +INSERT INTO frobulators (id, user_id, org_id, model_number) +VALUES (gen_random_uuid(), $1, $2, $3) +RETURNING *; --- name: InsertFrobulator :exec -INSERT INTO frobulators (id, user_id, model_number) -VALUES ($1, $2, $3); \ No newline at end of file +-- name: DeleteFrobulator :exec +DELETE FROM frobulators +WHERE id = $1 AND user_id = $2 AND org_id = $3; \ No newline at end of file diff --git a/coderd/frobulators.go b/coderd/frobulators.go index ba8b203f8d956..e2712ed4ce75a 100644 --- a/coderd/frobulators.go +++ b/coderd/frobulators.go @@ -1,8 +1,10 @@ package coderd import ( + "fmt" "net/http" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/coder/coder/v2/coderd/database" @@ -20,20 +22,20 @@ import ( // @Produce json // @Tags Frobulator // @Success 200 "New frobulator ID" -// @Router /frobulators/{user} [post] +// @Router /organizations/{organization}/frobulators/{user} [post] func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) + org := httpmw.OrganizationParam(r) var req codersdk.InsertFrobulatorRequest if !httpapi.Read(ctx, rw, r, &req) { return } - newID := uuid.New() - err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{ - ID: newID, + frob, err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{ UserID: user.ID, + OrgID: org.ID, ModelNumber: req.ModelNumber, }) if err != nil { @@ -41,21 +43,26 @@ func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(r.Context(), rw, http.StatusOK, newID.String()) + httpapi.Write(r.Context(), rw, http.StatusOK, frob.ID.String()) } -// @Summary Get user frobulators -// @ID get-user-frobulators +// @Summary Get frobulators +// @ID get-frobulators // @Security CoderSessionToken // @Param user path string true "User ID, name, or me" // @Produce json // @Tags Frobulator // @Success 200 {array} codersdk.Frobulator -// @Router /frobulators/{user} [get] -func (api *API) listUserFrobulators(rw http.ResponseWriter, r *http.Request) { +// @Router /organizations/{organization}/frobulators/{user} [get] +func (api *API) listFrobulators(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) - frobs, err := api.Database.GetUserFrobulators(ctx, user.ID) + org := httpmw.OrganizationParam(r) + + frobs, err := api.Database.GetFrobulators(ctx, database.GetFrobulatorsParams{ + UserID: user.ID, + OrgID: org.ID, + }) if err != nil { httpapi.InternalServerError(rw, err) return @@ -66,6 +73,7 @@ func (api *API) listUserFrobulators(rw http.ResponseWriter, r *http.Request) { out = append(out, codersdk.Frobulator{ ID: f.ID, UserID: f.UserID, + OrgID: f.OrgID, ModelNumber: f.ModelNumber, }) } @@ -73,21 +81,38 @@ func (api *API) listUserFrobulators(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, out) } -// @Summary Get all frobulators -// @ID get-all-frobulators +// @Summary Delete frobulator +// @ID delete-frobulator // @Security CoderSessionToken // @Produce json // @Tags Frobulator -// @Success 200 {array} codersdk.Frobulator -// @Router /frobulators [get] -func (api *API) listAllFrobulators(rw http.ResponseWriter, r *http.Request) { +// @Success 200 +// @Router /organizations/{organization}/frobulators/{user}/{id} [get] +func (api *API) deleteFrobulator(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - frobs, err := api.Database.GetAllFrobulators(ctx) + idParam := chi.URLParam(r, "id") + id, err := uuid.Parse(idParam) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Frobulator ID %q must be a valid UUID.", id), + Detail: err.Error(), + }) + return + } + + user := httpmw.UserParam(r) + org := httpmw.OrganizationParam(r) + + err = api.Database.DeleteFrobulator(ctx, database.DeleteFrobulatorParams{ + ID: id, + UserID: user.ID, + OrgID: org.ID, + }) if err != nil { httpapi.InternalServerError(rw, err) return } - httpapi.Write(r.Context(), rw, http.StatusOK, frobs) + httpapi.Write(r.Context(), rw, http.StatusOK, nil) } diff --git a/coderd/frobulators_test.go b/coderd/frobulators_test.go index 709325d3dfe90..03ac1b9c5969e 100644 --- a/coderd/frobulators_test.go +++ b/coderd/frobulators_test.go @@ -4,11 +4,14 @@ import ( "fmt" "net/http" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -18,101 +21,215 @@ func TestFrobulators(t *testing.T) { t.Parallel() // Setup for all tests - api := coderdtest.New(t, nil) + api, store := coderdtest.NewWithDatabase(t, nil) setupCtx := testutil.Context(t, testutil.WaitShort) - firstUser := coderdtest.CreateFirstUser(t, api) + coderdtest.CreateFirstUser(t, api) + + org1 := dbgen.Organization(t, store, database.Organization{ + ID: uuid.New(), + Name: "test-org1", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + org2 := dbgen.Organization(t, store, database.Organization{ + ID: uuid.New(), + Name: "test-org2", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) // Create 2 member, add one frobulator each - memberClient1, otherUser1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) - memberClient2, otherUser2 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + memberClient1, member1 := coderdtest.CreateAnotherUser(t, api, org1.ID) + memberClient2, member2 := coderdtest.CreateAnotherUser(t, api, org2.ID) - frobulatorID, err := memberClient1.CreateFrobulator(setupCtx, otherUser1.ID, fmt.Sprintf("model-%s", uuid.NewString())) + frobulatorID, err := memberClient1.CreateFrobulator(setupCtx, member1.ID, org1.ID, fmt.Sprintf("model-%s", uuid.NewString())) require.NoError(t, err) require.NotNil(t, frobulatorID) - frobulator2ID, err := memberClient2.CreateFrobulator(setupCtx, otherUser2.ID, fmt.Sprintf("model2-%s", uuid.NewString())) + frobulator2ID, err := memberClient2.CreateFrobulator(setupCtx, member2.ID, org2.ID, fmt.Sprintf("model2-%s", uuid.NewString())) require.NoError(t, err) require.NotNil(t, frobulator2ID) t.Run("Read other members' frobulators", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitShort) + tests := []struct { + name string + user codersdk.User + org database.Organization + }{ + { + name: "same org", + user: member1, + org: org1, + }, + { + name: "different org", + user: member1, + org: org2, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) - // Given: a new member - memberClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + // Given: a new member in the given group + memberClient, _ := coderdtest.CreateAnotherUser(t, api, tc.org.ID) - // When: attempting to view the frobulators of another user - frobs, err := memberClient.GetUserFrobulators(ctx, otherUser1.ID) + // When: attempting to view the frobulators of another user + frobs, err := memberClient.GetFrobulators(ctx, tc.user.ID, tc.org.ID) - // Then: validate that access was denied - require.Nil(t, frobs) + // Then: validate that no frobulators were returned + require.Nil(t, frobs) - var sdkError *codersdk.Error - require.Error(t, err) - require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") - // Unfortunately, the ExtractUserParam middleware returns a 400 Bad Request not a 403 Forbidden for unauthorized requests. - require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + // Then: validate that access was denied + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + // Unfortunately, the ExtractUserParam middleware returns a 400 Bad Request not a 403 Forbidden for unauthorized requests. + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + }) + } }) t.Run("Create and read own frobulators", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) + org := org1 // Given: a user which is not an admin - memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + memberClient, member := coderdtest.CreateAnotherUser(t, api, org.ID) // When: attempting to create a frobulator - id, err := memberClient.CreateFrobulator(ctx, member.ID, fmt.Sprintf("model-%s", uuid.NewString())) + id, err := memberClient.CreateFrobulator(ctx, member.ID, org.ID, fmt.Sprintf("model-%s", uuid.NewString())) // Then: it should succeed and should be queryable require.NoError(t, err) require.NotNil(t, id) - frobs, err := memberClient.GetUserFrobulators(ctx, member.ID) + frobs, err := memberClient.GetFrobulators(ctx, member.ID, org.ID) require.NoError(t, err) require.Len(t, frobs, 1) require.Equal(t, id, frobs[0].ID) }) - t.Run("Access all frobulators as admin", func(t *testing.T) { + t.Run("Access users' frobulators as admin", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitShort) - - // Given: an owner - which has access to all other users' frobulators - adminClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID, rbac.RoleOwner()) - - // When: accessing all frobulators - frobs, err := adminClient.GetAllFrobulators(ctx) + tests := []struct { + name string + member codersdk.User + memberOrg database.Organization + adminOrg database.Organization + role rbac.RoleIdentifier + expectedFrob uuid.UUID + expectedErr string + }{ + { + name: "owner, same org", + member: member1, + memberOrg: org1, + adminOrg: org1, + role: rbac.RoleOwner(), + expectedFrob: frobulatorID, + }, + { + name: "owner, different org", + member: member2, + memberOrg: org2, + adminOrg: org1, + role: rbac.RoleOwner(), + expectedFrob: frobulator2ID, + }, + { + name: "org admin, same org", + member: member2, + memberOrg: org2, + adminOrg: org2, + role: rbac.ScopedRoleOrgAdmin(org2.ID), + expectedFrob: frobulator2ID, + }, + { + // Org admins do not have permission outside of their own org. + name: "org admin, diff org", + member: member2, + memberOrg: org2, + adminOrg: org1, + role: rbac.ScopedRoleOrgAdmin(org1.ID), + expectedErr: "404: Resource not found", + }, + { + // User admins do not have permissions even inside their own org. + name: "user admin, same org", + member: member2, + memberOrg: org2, + adminOrg: org2, + role: rbac.ScopedRoleOrgUserAdmin(org2.ID), + expectedErr: "500: An internal server error occurred", + }, + } - // Then: the expected number of frobulators are returned - require.NoError(t, err) - require.GreaterOrEqual(t, len(frobs), 2) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: a new user of the defined role + client, _ := coderdtest.CreateAnotherUser(t, api, tc.adminOrg.ID, tc.role) + + // When: accessing the frobulators of a user in an org + frobs, err := client.GetFrobulators(ctx, tc.member.ID, tc.memberOrg.ID) + + // Then: if an error is expected, validate that we received what we expect + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + return + } + + // Then: the expected frobulator should be returned + require.NoError(t, err) + require.Len(t, frobs, 1) + + var found bool + for _, f := range frobs { + if f.ID == tc.expectedFrob { + found = true + } + } + require.True(t, found, "reference frobulator not found") + }) + } }) - t.Run("Access specific users' frobulators as admin", func(t *testing.T) { + t.Run("Create and delete own frobulators", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) + org := org1 - // Given: an owner - which has access to all other users' frobulators - adminClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID, rbac.RoleOwner()) + // Given: a user which is not an admin + memberClient, member := coderdtest.CreateAnotherUser(t, api, org.ID) - // When: accessing the frobulators of another user in their org - frobs, err := adminClient.GetUserFrobulators(ctx, otherUser1.ID) + // When: attempting to create a frobulator + id, err := memberClient.CreateFrobulator(ctx, member.ID, org.ID, fmt.Sprintf("model-%s", uuid.NewString())) - // Then: the expected frobulator should be returned + // Then: it should succeed and should be queryable + require.NoError(t, err) + require.NotNil(t, id) + frobs, err := memberClient.GetFrobulators(ctx, member.ID, org.ID) require.NoError(t, err) require.Len(t, frobs, 1) + require.Equal(t, id, frobs[0].ID) - var found bool - for _, f := range frobs { - if f.ID == frobulatorID { - found = true - } - } - require.True(t, found, "reference frobulator not found") + // When: attempting to delete a frobulator + err = memberClient.DeleteFrobulator(ctx, id, member.ID, org.ID) + + // Then: it should succeed and the frobulator will no longer be present + require.NoError(t, err) + frobs, err = memberClient.GetFrobulators(ctx, member.ID, org.ID) + require.NoError(t, err) + require.Len(t, frobs, 0) }) } diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 6243d5a307bfa..e0e87c704293d 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -87,10 +87,10 @@ var ( // ResourceFrobulator // Valid Actions - // - "ActionCreate" :: - // - "ActionDelete" :: - // - "ActionRead" :: - // - "ActionUpdate" :: + // - "ActionCreate" :: create a frobulator + // - "ActionDelete" :: delete a frobulator + // - "ActionRead" :: read a frobulator + // - "ActionUpdate" :: update a frobulator ResourceFrobulator = Object{ Type: "frobulator", } diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 3ca9d951266e8..6e74f41826604 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -276,10 +276,10 @@ var RBACPermissions = map[string]PermissionDefinition{ }, "frobulator": { Actions: map[Action]ActionDefinition{ - ActionCreate: actDef(""), - ActionRead: actDef(""), - ActionUpdate: actDef(""), - ActionDelete: actDef(""), + ActionCreate: {Description: "create a frobulator"}, + ActionRead: {Description: "read a frobulator"}, + ActionUpdate: {Description: "update a frobulator"}, + ActionDelete: {Description: "delete a frobulator"}, }, }, } diff --git a/codersdk/frobulators.go b/codersdk/frobulators.go index 32a2c2f2a7239..ffb571bd82fc3 100644 --- a/codersdk/frobulators.go +++ b/codersdk/frobulators.go @@ -12,6 +12,7 @@ import ( type Frobulator struct { ID uuid.UUID `json:"id" format:"uuid"` UserID uuid.UUID `json:"user_id" format:"uuid"` + OrgID uuid.UUID `json:"org_id" format:"uuid"` ModelNumber string `json:"model_number"` } @@ -19,12 +20,12 @@ type InsertFrobulatorRequest struct { ModelNumber string `json:"model_number"` } -func (c *Client) CreateFrobulator(ctx context.Context, userID uuid.UUID, modelNumber string) (uuid.UUID, error) { +func (c *Client) CreateFrobulator(ctx context.Context, userID, orgID uuid.UUID, modelNumber string) (uuid.UUID, error) { req := InsertFrobulatorRequest{ ModelNumber: modelNumber, } - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/frobulators/%s", userID.String()), req) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/frobulators/%s", orgID.String(), userID.String()), req) if err != nil { return uuid.Nil, err } @@ -38,8 +39,8 @@ func (c *Client) CreateFrobulator(ctx context.Context, userID uuid.UUID, modelNu return newID, json.NewDecoder(res.Body).Decode(&newID) } -func (c *Client) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Frobulator, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/frobulators/%s", userID.String()), nil) +func (c *Client) GetFrobulators(ctx context.Context, userID, orgID uuid.UUID) ([]Frobulator, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/frobulators/%s", orgID.String(), userID.String()), nil) if err != nil { return nil, err } @@ -53,17 +54,16 @@ func (c *Client) GetUserFrobulators(ctx context.Context, userID uuid.UUID) ([]Fr return frobulators, json.NewDecoder(res.Body).Decode(&frobulators) } -func (c *Client) GetAllFrobulators(ctx context.Context) ([]Frobulator, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/frobulators", nil) +func (c *Client) DeleteFrobulator(ctx context.Context, id, userID, orgID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/frobulators/%s/%s", orgID.String(), userID.String(), id.String()), nil) if err != nil { - return nil, err + return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) + return ReadBodyAsError(res) } - var frobulators []Frobulator - return frobulators, json.NewDecoder(res.Body).Decode(&frobulators) + return nil } diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index a402864a31659..2ac2693aeb691 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -47,10 +47,10 @@ export const RBACResourceActions: Partial< read: "read files", }, frobulator: { - create: "", - delete: "", - read: "", - update: "", + create: "create a frobulator", + delete: "delete a frobulator", + read: "read a frobulator", + update: "update a frobulator", }, group: { create: "create a group", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b1af00375dfb4..4dfb65531c9a8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -596,6 +596,14 @@ export interface Feature { readonly actual?: number } +// From codersdk/frobulators.go +export interface Frobulator { + readonly id: string + readonly user_id: string + readonly org_id: string + readonly model_number: string +} + // From codersdk/apikey.go export interface GenerateAPIKeyResponse { readonly key: string @@ -647,6 +655,11 @@ export interface HealthcheckConfig { readonly threshold_database: number } +// From codersdk/frobulators.go +export interface InsertFrobulatorRequest { + readonly model_number: string +} + // From codersdk/workspaceagents.go export interface IssueReconnectingPTYSignedTokenRequest { readonly url: string @@ -2114,8 +2127,8 @@ export type RBACAction = "application_connect" | "assign" | "create" | "delete" export const RBACActions: RBACAction[] = ["application_connect", "assign", "create", "delete", "read", "read_personal", "ssh", "start", "stop", "update", "update_personal", "use", "view_insights"] // From codersdk/rbacresources_gen.go -export type RBACResource = "*" | "api_key" | "assign_org_role" | "assign_role" | "audit_log" | "debug_info" | "deployment_config" | "deployment_stats" | "file" | "group" | "group_member" | "license" | "notification_preference" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" | "organization" | "organization_member" | "provisioner_daemon" | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" | "template" | "user" | "workspace" | "workspace_dormant" | "workspace_proxy" -export const RBACResources: RBACResource[] = ["*", "api_key", "assign_org_role", "assign_role", "audit_log", "debug_info", "deployment_config", "deployment_stats", "file", "group", "group_member", "license", "notification_preference", "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", "organization", "organization_member", "provisioner_daemon", "provisioner_keys", "replicas", "system", "tailnet_coordinator", "template", "user", "workspace", "workspace_dormant", "workspace_proxy"] +export type RBACResource = "*" | "api_key" | "assign_org_role" | "assign_role" | "audit_log" | "debug_info" | "deployment_config" | "deployment_stats" | "file" | "frobulator" | "group" | "group_member" | "license" | "notification_preference" | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" | "organization" | "organization_member" | "provisioner_daemon" | "provisioner_keys" | "replicas" | "system" | "tailnet_coordinator" | "template" | "user" | "workspace" | "workspace_dormant" | "workspace_proxy" +export const RBACResources: RBACResource[] = ["*", "api_key", "assign_org_role", "assign_role", "audit_log", "debug_info", "deployment_config", "deployment_stats", "file", "frobulator", "group", "group_member", "license", "notification_preference", "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", "organization", "organization_member", "provisioner_daemon", "provisioner_keys", "replicas", "system", "tailnet_coordinator", "template", "user", "workspace", "workspace_dormant", "workspace_proxy"] // From codersdk/audit.go export type ResourceType = "api_key" | "convert_login" | "custom_role" | "git_ssh_key" | "group" | "health_settings" | "license" | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" | "template" | "template_version" | "user" | "workspace" | "workspace_build" | "workspace_proxy" From 0ed8de7470ca909e45c5fdc50f8a94493fc3e9a0 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 6 Sep 2024 09:47:03 +0200 Subject: [PATCH 06/16] Fixup Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 244 ++++++++++-------- coderd/apidoc/swagger.json | 218 +++++++++------- coderd/coderd.go | 21 +- coderd/database/dbmetrics/dbmetrics.go | 4 +- ....down.sql => 000249_frobulations.down.sql} | 0 ...ions.up.sql => 000249_frobulations.up.sql} | 0 coderd/frobulators.go | 88 ++++--- coderd/frobulators_test.go | 5 +- codersdk/frobulators.go | 8 +- docs/reference/api/frobulator.md | 98 +++---- docs/reference/api/schemas.md | 2 + site/src/api/typesGenerated.ts | 10 +- 12 files changed, 375 insertions(+), 323 deletions(-) rename coderd/database/migrations/{000245_frobulations.down.sql => 000249_frobulations.down.sql} (100%) rename coderd/database/migrations/{000245_frobulations.up.sql => 000249_frobulations.up.sql} (100%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 50644884f0bb3..87804a038c7cb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1033,112 +1033,6 @@ const docTemplate = `{ } } }, - "/frobulators": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Frobulator" - ], - "summary": "Get all frobulators", - "operationId": "get-all-frobulators", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Frobulator" - } - } - } - } - } - }, - "/frobulators/{user}": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Frobulator" - ], - "summary": "Get user frobulators", - "operationId": "get-user-frobulators", - "parameters": [ - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Frobulator" - } - } - } - } - }, - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Frobulator" - ], - "summary": "Post frobulator", - "operationId": "post-frobulator", - "parameters": [ - { - "description": "Insert Frobulator request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.InsertFrobulatorRequest" - } - }, - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "New frobulator ID" - } - } - } - }, "/groups": { "get": { "security": [ @@ -2866,6 +2760,140 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members/{user}/frobulators": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Frobulator" + ], + "summary": "Get frobulators", + "operationId": "get-frobulators", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Frobulator" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Frobulator" + ], + "summary": "Post frobulator", + "operationId": "post-frobulator", + "parameters": [ + { + "description": "Insert Frobulator request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.InsertFrobulatorRequest" + } + }, + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "New frobulator ID" + } + } + } + }, + "/organizations/{organization}/members/{user}/frobulators/{id}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Frobulator" + ], + "summary": "Delete frobulator", + "operationId": "delete-frobulator", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Frobulator ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/organizations/{organization}/members/{user}/roles": { "put": { "security": [ @@ -10395,6 +10423,10 @@ const docTemplate = `{ "model_number": { "type": "string" }, + "org_id": { + "type": "string", + "format": "uuid" + }, "user_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a96e28ac0d7aa..b1875d5fdff3a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -891,98 +891,6 @@ } } }, - "/frobulators": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Frobulator"], - "summary": "Get all frobulators", - "operationId": "get-all-frobulators", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Frobulator" - } - } - } - } - } - }, - "/frobulators/{user}": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Frobulator"], - "summary": "Get user frobulators", - "operationId": "get-user-frobulators", - "parameters": [ - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Frobulator" - } - } - } - } - }, - "post": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Frobulator"], - "summary": "Post frobulator", - "operationId": "post-frobulator", - "parameters": [ - { - "description": "Insert Frobulator request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.InsertFrobulatorRequest" - } - }, - { - "type": "string", - "description": "User ID, name, or me", - "name": "user", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "New frobulator ID" - } - } - } - }, "/groups": { "get": { "security": [ @@ -2510,6 +2418,128 @@ } } }, + "/organizations/{organization}/members/{user}/frobulators": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Frobulator"], + "summary": "Get frobulators", + "operationId": "get-frobulators", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Frobulator" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Frobulator"], + "summary": "Post frobulator", + "operationId": "post-frobulator", + "parameters": [ + { + "description": "Insert Frobulator request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.InsertFrobulatorRequest" + } + }, + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "New frobulator ID" + } + } + } + }, + "/organizations/{organization}/members/{user}/frobulators/{id}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Frobulator"], + "summary": "Delete frobulator", + "operationId": "delete-frobulator", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Frobulator ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/organizations/{organization}/members/{user}/roles": { "put": { "security": [ @@ -9325,6 +9355,10 @@ "model_number": { "type": "string" }, + "org_id": { + "type": "string", + "format": "uuid" + }, "user_id": { "type": "string", "format": "uuid" diff --git a/coderd/coderd.go b/coderd/coderd.go index 7e973f41e0110..db2388d3c0f78 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,11 +37,12 @@ import ( "tailscale.com/util/singleflight" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/entitlements" - "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/quartz" "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/entitlements" + "github.com/coder/coder/v2/coderd/idpsync" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/buildinfo" _ "github.com/coder/coder/v2/coderd/apidoc" // Used for swagger docs. @@ -936,20 +937,14 @@ func New(options *Options) *API { r.Delete("/", api.deleteOrganizationMember) r.Put("/roles", api.putMemberRoles) r.Post("/workspaces", api.postWorkspacesByOrganization) + r.Route("/frobulators", func(r chi.Router) { + r.Get("/", api.listFrobulators) + r.Post("/", api.createFrobulator) + r.Delete("/{id}", api.deleteFrobulator) + }) }) }) }) - r.Route("/frobulators", func(r chi.Router) { - r.Use(apiKeyMiddleware) - r.Route("/{user}", func(r chi.Router) { - r.Use( - httpmw.ExtractUserParam(options.Database), - ) - r.Get("/", api.listFrobulators) - r.Post("/", api.createFrobulator) - r.Delete("/{id}", api.deleteFrobulator) - }) - }) }) }) r.Route("/templates", func(r chi.Router) { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 5c98732dd6bed..7adfd333db20c 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1637,9 +1637,9 @@ func (m metricsStore) InsertFile(ctx context.Context, arg database.InsertFilePar func (m metricsStore) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) (database.Frobulator, error) { start := time.Now() - r0 := m.s.InsertFrobulator(ctx, arg) + r0, r1 := m.s.InsertFrobulator(ctx, arg) m.queryLatencies.WithLabelValues("InsertFrobulator").Observe(time.Since(start).Seconds()) - return r0 + return r0, r1 } func (m metricsStore) InsertGitSSHKey(ctx context.Context, arg database.InsertGitSSHKeyParams) (database.GitSSHKey, error) { diff --git a/coderd/database/migrations/000245_frobulations.down.sql b/coderd/database/migrations/000249_frobulations.down.sql similarity index 100% rename from coderd/database/migrations/000245_frobulations.down.sql rename to coderd/database/migrations/000249_frobulations.down.sql diff --git a/coderd/database/migrations/000245_frobulations.up.sql b/coderd/database/migrations/000249_frobulations.up.sql similarity index 100% rename from coderd/database/migrations/000245_frobulations.up.sql rename to coderd/database/migrations/000249_frobulations.up.sql diff --git a/coderd/frobulators.go b/coderd/frobulators.go index e2712ed4ce75a..b472cc9b6d56d 100644 --- a/coderd/frobulators.go +++ b/coderd/frobulators.go @@ -13,54 +13,22 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// @Summary Post frobulator -// @ID post-frobulator -// @Security CoderSessionToken -// @Param request body codersdk.InsertFrobulatorRequest true "Insert Frobulator request" -// @Param user path string true "User ID, name, or me" -// @Accept json -// @Produce json -// @Tags Frobulator -// @Success 200 "New frobulator ID" -// @Router /organizations/{organization}/frobulators/{user} [post] -func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := httpmw.UserParam(r) - org := httpmw.OrganizationParam(r) - - var req codersdk.InsertFrobulatorRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - frob, err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{ - UserID: user.ID, - OrgID: org.ID, - ModelNumber: req.ModelNumber, - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - - httpapi.Write(r.Context(), rw, http.StatusOK, frob.ID.String()) -} - // @Summary Get frobulators // @ID get-frobulators // @Security CoderSessionToken +// @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" // @Produce json // @Tags Frobulator // @Success 200 {array} codersdk.Frobulator -// @Router /organizations/{organization}/frobulators/{user} [get] +// @Router /organizations/{organization}/members/{user}/frobulators [get] func (api *API) listFrobulators(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - user := httpmw.UserParam(r) + member := httpmw.OrganizationMemberParam(r) org := httpmw.OrganizationParam(r) frobs, err := api.Database.GetFrobulators(ctx, database.GetFrobulatorsParams{ - UserID: user.ID, + UserID: member.UserID, OrgID: org.ID, }) if err != nil { @@ -81,13 +49,49 @@ func (api *API) listFrobulators(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, out) } +// @Summary Post frobulator +// @ID post-frobulator +// @Security CoderSessionToken +// @Param request body codersdk.InsertFrobulatorRequest true "Insert Frobulator request" +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Accept json +// @Produce json +// @Tags Frobulator +// @Success 200 "New frobulator ID" +// @Router /organizations/{organization}/members/{user}/frobulators [post] +func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + member := httpmw.OrganizationMemberParam(r) + org := httpmw.OrganizationParam(r) + + var req codersdk.InsertFrobulatorRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + frob, err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{ + UserID: member.UserID, + OrgID: org.ID, + ModelNumber: req.ModelNumber, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, frob.ID.String()) +} + // @Summary Delete frobulator // @ID delete-frobulator // @Security CoderSessionToken -// @Produce json +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Param id path string true "Frobulator ID" // @Tags Frobulator -// @Success 200 -// @Router /organizations/{organization}/frobulators/{user}/{id} [get] +// @Success 204 +// @Router /organizations/{organization}/members/{user}/frobulators/{id} [delete] func (api *API) deleteFrobulator(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -101,12 +105,12 @@ func (api *API) deleteFrobulator(rw http.ResponseWriter, r *http.Request) { return } - user := httpmw.UserParam(r) + member := httpmw.OrganizationMemberParam(r) org := httpmw.OrganizationParam(r) err = api.Database.DeleteFrobulator(ctx, database.DeleteFrobulatorParams{ ID: id, - UserID: user.ID, + UserID: member.UserID, OrgID: org.ID, }) if err != nil { @@ -114,5 +118,5 @@ func (api *API) deleteFrobulator(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(r.Context(), rw, http.StatusOK, nil) + rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/frobulators_test.go b/coderd/frobulators_test.go index 03ac1b9c5969e..f9c941aa9155d 100644 --- a/coderd/frobulators_test.go +++ b/coderd/frobulators_test.go @@ -84,12 +84,11 @@ func TestFrobulators(t *testing.T) { // Then: validate that no frobulators were returned require.Nil(t, frobs) - // Then: validate that access was denied + // Then: validate that access was denied by receiving a 404 var sdkError *codersdk.Error require.Error(t, err) require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") - // Unfortunately, the ExtractUserParam middleware returns a 400 Bad Request not a 403 Forbidden for unauthorized requests. - require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, http.StatusNotFound, sdkError.StatusCode()) }) } }) diff --git a/codersdk/frobulators.go b/codersdk/frobulators.go index ffb571bd82fc3..f4bef011fc490 100644 --- a/codersdk/frobulators.go +++ b/codersdk/frobulators.go @@ -25,7 +25,7 @@ func (c *Client) CreateFrobulator(ctx context.Context, userID, orgID uuid.UUID, ModelNumber: modelNumber, } - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/frobulators/%s", orgID.String(), userID.String()), req) + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/members/%s/frobulators", orgID.String(), userID.String()), req) if err != nil { return uuid.Nil, err } @@ -40,7 +40,7 @@ func (c *Client) CreateFrobulator(ctx context.Context, userID, orgID uuid.UUID, } func (c *Client) GetFrobulators(ctx context.Context, userID, orgID uuid.UUID) ([]Frobulator, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/frobulators/%s", orgID.String(), userID.String()), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s/frobulators", orgID.String(), userID.String()), nil) if err != nil { return nil, err } @@ -55,13 +55,13 @@ func (c *Client) GetFrobulators(ctx context.Context, userID, orgID uuid.UUID) ([ } func (c *Client) DeleteFrobulator(ctx context.Context, id, userID, orgID uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/frobulators/%s/%s", orgID.String(), userID.String(), id.String()), nil) + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/members/%s/frobulators/%s", orgID.String(), userID.String(), id.String()), nil) if err != nil { return err } defer res.Body.Close() - if res.StatusCode != http.StatusOK { + if res.StatusCode != http.StatusNoContent { return ReadBodyAsError(res) } diff --git a/docs/reference/api/frobulator.md b/docs/reference/api/frobulator.md index 7cc6ec190cae2..46c01a9ef37ec 100644 --- a/docs/reference/api/frobulator.md +++ b/docs/reference/api/frobulator.md @@ -1,17 +1,24 @@ # Frobulator -## Get all frobulators +## Get frobulators ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/frobulators \ +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/frobulators \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /frobulators` +`GET /organizations/{organization}/members/{user}/frobulators` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | -------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | ### Example responses @@ -22,6 +29,7 @@ curl -X GET http://coder-server:8080/api/v2/frobulators \ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "model_number": "string", + "org_id": "a40f5d1f-d889-42e9-94ea-b9b33585fc6b", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ] @@ -33,7 +41,7 @@ curl -X GET http://coder-server:8080/api/v2/frobulators \ | ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Frobulator](schemas.md#codersdkfrobulator) | -

Response Schema

+

Response Schema

Status Code **200** @@ -42,94 +50,72 @@ Status Code **200** | `[array item]` | array | false | | | | `» id` | string(uuid) | false | | | | `» model_number` | string | false | | | +| `» org_id` | string(uuid) | false | | | | `» user_id` | string(uuid) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get user frobulators +## Post frobulator ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/frobulators/{user} \ - -H 'Accept: application/json' \ +curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/frobulators \ + -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /frobulators/{user}` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | ------ | -------- | -------------------- | -| `user` | path | string | true | User ID, name, or me | - -### Example responses +`POST /organizations/{organization}/members/{user}/frobulators` -> 200 Response +> Body parameter ```json -[ - { - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "model_number": "string", - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" - } -] +{ + "model_number": "string" +} ``` -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Frobulator](schemas.md#codersdkfrobulator) | +### Parameters -

Response Schema

+| Name | In | Type | Required | Description | +| -------------- | ---- | ------------------------------------------------------------------------------ | -------- | ------------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | +| `body` | body | [codersdk.InsertFrobulatorRequest](schemas.md#codersdkinsertfrobulatorrequest) | true | Insert Frobulator request | -Status Code **200** +### Responses -| Name | Type | Required | Restrictions | Description | -| ---------------- | ------------ | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» id` | string(uuid) | false | | | -| `» model_number` | string | false | | | -| `» user_id` | string(uuid) | false | | | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | New frobulator ID | | To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Post frobulator +## Delete frobulator ### Code samples ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/frobulators/{user} \ - -H 'Content-Type: application/json' \ +curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/frobulators/{id} \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /frobulators/{user}` - -> Body parameter - -```json -{ - "model_number": "string" -} -``` +`DELETE /organizations/{organization}/members/{user}/frobulators/{id}` ### Parameters -| Name | In | Type | Required | Description | -| ------ | ---- | ------------------------------------------------------------------------------ | -------- | ------------------------- | -| `user` | path | string | true | User ID, name, or me | -| `body` | body | [codersdk.InsertFrobulatorRequest](schemas.md#codersdkinsertfrobulatorrequest) | true | Insert Frobulator request | +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | -------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | +| `id` | path | string | true | Frobulator ID | ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------------- | ------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | New frobulator ID | | +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 51e34b5e234d7..f74936a933e37 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2766,6 +2766,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "model_number": "string", + "org_id": "a40f5d1f-d889-42e9-94ea-b9b33585fc6b", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` @@ -2776,6 +2777,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | -------------- | ------ | -------- | ------------ | ----------- | | `id` | string | false | | | | `model_number` | string | false | | | +| `org_id` | string | false | | | | `user_id` | string | false | | | ## codersdk.GenerateAPIKeyResponse diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e3b65a57f88af..e43ad71cb5550 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -595,10 +595,10 @@ export interface Feature { // From codersdk/frobulators.go export interface Frobulator { - readonly id: string - readonly user_id: string - readonly org_id: string - readonly model_number: string + readonly id: string; + readonly user_id: string; + readonly org_id: string; + readonly model_number: string; } // From codersdk/apikey.go @@ -656,7 +656,7 @@ export interface HealthcheckConfig { // From codersdk/frobulators.go export interface InsertFrobulatorRequest { - readonly model_number: string + readonly model_number: string; } // From codersdk/workspaceagents.go From b15c4229ad012c1412cfbdcc112af715bdb6abec Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 6 Sep 2024 10:24:43 +0200 Subject: [PATCH 07/16] RBAC fixup Signed-off-by: Danny Kopping --- coderd/rbac/roles.go | 2 ++ coderd/rbac/roles_test.go | 15 ++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 131791170a8bf..7aee8d1ddc2c1 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -445,6 +445,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Org: map[string][]Permission{ organizationID.String(): Permissions(map[string][]policy.Action{ ResourceAuditLog.Type: {policy.ActionRead}, + // The org-wide auditor is allowed to read *all* frobulators in their own org, regardless of who owns them. + ResourceFrobulator.Type: {policy.ActionRead}, }), }, User: []Permission{}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 3bbb9c6ff58e9..530ff72059018 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -691,13 +691,14 @@ func TestRolePermissions(t *testing.T) { }, }, { - // Owner should be able to CRUD any other user's frobulators - Name: "FrobulatorsAnyUser", - Actions: []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, - Resource: rbac.ResourceFrobulator.WithOwner(uuid.New().String()), // read frobulators of any user + // Owner should be able to modify any other user's frobulators in their own org + // Org admin should be able to modify any other user's frobulators in their own org + Name: "FrobulatorsModifyAnyUser", + Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + Resource: rbac.ResourceFrobulator.WithOwner(uuid.New().String()).InOrg(orgID), // read frobulators of any user AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner}, - false: {memberMe, orgMemberMe, orgAdmin, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + true: {owner, orgAdmin}, + false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -706,7 +707,7 @@ func TestRolePermissions(t *testing.T) { // Owner should be able to read any other user's frobulators Name: "FrobulatorsReadAnyUserInOrg", Actions: []policy.Action{policy.ActionRead}, - Resource: rbac.ResourceFrobulator.InOrg(orgID).WithOwner(uuid.New().String()), // read frobulators of any user + Resource: rbac.ResourceFrobulator.WithOwner(uuid.New().String()).InOrg(orgID), // read frobulators of any user AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgAuditor}, false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin}, From cd2bbaeca240bd920f6a4c3ae34bd12696c32866 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 6 Sep 2024 10:42:52 +0200 Subject: [PATCH 08/16] Fixtures Signed-off-by: Danny Kopping --- .../testdata/fixtures/000249_frobulations.up.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 coderd/database/migrations/testdata/fixtures/000249_frobulations.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000249_frobulations.up.sql b/coderd/database/migrations/testdata/fixtures/000249_frobulations.up.sql new file mode 100644 index 0000000000000..0c95ef7c1153d --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000249_frobulations.up.sql @@ -0,0 +1,7 @@ +INSERT INTO frobulators (id, user_id, org_id, model_number) +VALUES + (gen_random_uuid(), '30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 'FRB-1001'), + (gen_random_uuid(), '30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 'FRB-1002'), + (gen_random_uuid(), '30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 'FRB-1003'), + (gen_random_uuid(), '30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 'FRB-1004'), + (gen_random_uuid(), '30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 'FRB-1005'); \ No newline at end of file From a0b9ec1b81766513209c4c8700608a8e59f38904 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 08:23:22 +0100 Subject: [PATCH 09/16] Apply suggestions from code review Co-authored-by: Steven Masley --- coderd/database/dbauthz/dbauthz.go | 5 +---- coderd/database/dbgen/dbgen.go | 6 +++--- coderd/database/queries/frobulators.sql | 2 +- coderd/frobulators.go | 8 ++++++++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8950394111ea8..b8bb82e154f92 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1477,10 +1477,7 @@ func (q *querier) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]dat } func (q *querier) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceFrobulator.WithOwner(arg.UserID.String()).InOrg(arg.OrgID)); err != nil { - return nil, err - } - return q.db.GetFrobulators(ctx, arg) +return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFrobulators)(ctx, arg) } func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 49a5329d23e9b..43c9679efc9e8 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -894,9 +894,9 @@ func CustomRole(t testing.TB, db database.Store, seed database.CustomRole) datab func Frobulator(t testing.TB, db database.Store, orig database.Frobulator) database.Frobulator { frob, err := db.InsertFrobulator(genCtx, database.InsertFrobulatorParams{ - UserID: orig.UserID, - OrgID: orig.OrgID, - ModelNumber: orig.ModelNumber, + UserID: takeFirst(orig.UserID, uuid.New()), + OrgID: takeFirst(orig.OrgID, uuid.New()), + ModelNumber: takeFirst(orig.ModelNumber, testutil.GetRandomName(t)), }) require.NoError(t, err, "insert frobulator") return frob diff --git a/coderd/database/queries/frobulators.sql b/coderd/database/queries/frobulators.sql index fd7362e2a3f93..d39e0fe02d2aa 100644 --- a/coderd/database/queries/frobulators.sql +++ b/coderd/database/queries/frobulators.sql @@ -5,7 +5,7 @@ WHERE user_id = $1 AND org_id = $2; -- name: InsertFrobulator :one INSERT INTO frobulators (id, user_id, org_id, model_number) -VALUES (gen_random_uuid(), $1, $2, $3) +VALUES ($1, $2, $3, $4) RETURNING *; -- name: DeleteFrobulator :exec diff --git a/coderd/frobulators.go b/coderd/frobulators.go index b472cc9b6d56d..7d9e23c85cbfd 100644 --- a/coderd/frobulators.go +++ b/coderd/frobulators.go @@ -75,6 +75,10 @@ func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { OrgID: org.ID, ModelNumber: req.ModelNumber, }) + if httpapi.Is404Error(err) { // Catches forbidden errors as well + httpapi.ResourceNotFound(rw) + return + } if err != nil { httpapi.InternalServerError(rw, err) return @@ -113,6 +117,10 @@ func (api *API) deleteFrobulator(rw http.ResponseWriter, r *http.Request) { UserID: member.UserID, OrgID: org.ID, }) + if httpapi.Is404Error(err) { // Catches forbidden errors as well + httpapi.ResourceNotFound(rw) + return + } if err != nil { httpapi.InternalServerError(rw, err) return From e53fd084e9a2174e4261c3f356b838bdff34133e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 08:48:54 +0100 Subject: [PATCH 10/16] make gen --- coderd/database/dbauthz/dbauthz.go | 2 +- coderd/database/queries.sql.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b8bb82e154f92..c6209b7c0c146 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1477,7 +1477,7 @@ func (q *querier) GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]dat } func (q *querier) GetFrobulators(ctx context.Context, arg database.GetFrobulatorsParams) ([]database.Frobulator, error) { -return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFrobulators)(ctx, arg) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetFrobulators)(ctx, arg) } func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2c9f7894607ba..ace92125abb1c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1248,18 +1248,24 @@ func (q *sqlQuerier) GetFrobulators(ctx context.Context, arg GetFrobulatorsParam const insertFrobulator = `-- name: InsertFrobulator :one INSERT INTO frobulators (id, user_id, org_id, model_number) -VALUES (gen_random_uuid(), $1, $2, $3) +VALUES ($1, $2, $3, $4) RETURNING id, user_id, org_id, model_number ` type InsertFrobulatorParams struct { + ID uuid.UUID `db:"id" json:"id"` UserID uuid.UUID `db:"user_id" json:"user_id"` OrgID uuid.UUID `db:"org_id" json:"org_id"` ModelNumber string `db:"model_number" json:"model_number"` } func (q *sqlQuerier) InsertFrobulator(ctx context.Context, arg InsertFrobulatorParams) (Frobulator, error) { - row := q.db.QueryRowContext(ctx, insertFrobulator, arg.UserID, arg.OrgID, arg.ModelNumber) + row := q.db.QueryRowContext(ctx, insertFrobulator, + arg.ID, + arg.UserID, + arg.OrgID, + arg.ModelNumber, + ) var i Frobulator err := row.Scan( &i.ID, From 52daccecf2e37b0c42e26ed236a73db16eca178c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 08:49:06 +0100 Subject: [PATCH 11/16] fix dbauthz tests --- coderd/database/dbauthz/dbauthz_test.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 643ef875b4718..06d935c732c20 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2777,15 +2777,14 @@ func (s *MethodTestSuite) TestFrobulators() { s.Run("GetFrobulators", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) org := dbgen.Organization(s.T(), db, database.Organization{}) + // Create a frobulator resource. + fr := dbgen.Frobulator(s.T(), db, database.Frobulator{UserID: user.ID, OrgID: org.ID}) + // Assert that calling GetFrobulators with the user and org ID records a + // read action on the above resource. check.Args(database.GetFrobulatorsParams{ UserID: user.ID, OrgID: org.ID, - }).Asserts( - rbac.ResourceFrobulator. - WithOwner(user.ID.String()). - InOrg(org.ID), - policy.ActionRead, - ) + }).Asserts(fr, policy.ActionRead) })) s.Run("InsertFrobulator", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) From 0212d71893ec0f13941fdeb49571be449897e45b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 08:56:00 +0100 Subject: [PATCH 12/16] create a few frobulators for fetchWithPostFilter --- coderd/database/dbauthz/dbauthz_test.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 06d935c732c20..0f54262f71852 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2775,16 +2775,20 @@ func (s *MethodTestSuite) TestNotifications() { func (s *MethodTestSuite) TestFrobulators() { s.Run("GetFrobulators", s.Subtest(func(db database.Store, check *expects) { - user := dbgen.User(s.T(), db, database.User{}) + // Pre-requisite: create two users and an organization. + u1 := dbgen.User(s.T(), db, database.User{}) + u2 := dbgen.User(s.T(), db, database.User{}) org := dbgen.Organization(s.T(), db, database.Organization{}) - // Create a frobulator resource. - fr := dbgen.Frobulator(s.T(), db, database.Frobulator{UserID: user.ID, OrgID: org.ID}) - // Assert that calling GetFrobulators with the user and org ID records a - // read action on the above resource. + // Create a few frobulator resources: two owned by u1, one owned by u2. + fr1 := dbgen.Frobulator(s.T(), db, database.Frobulator{UserID: u1.ID, OrgID: org.ID}) + fr2 := dbgen.Frobulator(s.T(), db, database.Frobulator{UserID: u1.ID, OrgID: org.ID}) + _ = dbgen.Frobulator(s.T(), db, database.Frobulator{UserID: u2.ID, OrgID: org.ID}) + // Assert that calling GetFrobulators with a given user and org ID records a + // read action on each of the resources owned by that user. check.Args(database.GetFrobulatorsParams{ - UserID: user.ID, + UserID: u1.ID, OrgID: org.ID, - }).Asserts(fr, policy.ActionRead) + }).Asserts(fr1, policy.ActionRead, fr2, policy.ActionRead) })) s.Run("InsertFrobulator", s.Subtest(func(db database.Store, check *expects) { user := dbgen.User(s.T(), db, database.User{}) From 6c795c0ddb8d935253492a0d369c2c81c12d368a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 09:14:10 +0100 Subject: [PATCH 13/16] generate frobulator ID --- coderd/database/dbgen/dbgen.go | 1 + coderd/frobulators.go | 1 + 2 files changed, 2 insertions(+) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 43c9679efc9e8..e4d9de6cf00e6 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -894,6 +894,7 @@ func CustomRole(t testing.TB, db database.Store, seed database.CustomRole) datab func Frobulator(t testing.TB, db database.Store, orig database.Frobulator) database.Frobulator { frob, err := db.InsertFrobulator(genCtx, database.InsertFrobulatorParams{ + ID: takeFirst(orig.ID, uuid.New()), UserID: takeFirst(orig.UserID, uuid.New()), OrgID: takeFirst(orig.OrgID, uuid.New()), ModelNumber: takeFirst(orig.ModelNumber, testutil.GetRandomName(t)), diff --git a/coderd/frobulators.go b/coderd/frobulators.go index 7d9e23c85cbfd..5d52d379f2f8a 100644 --- a/coderd/frobulators.go +++ b/coderd/frobulators.go @@ -71,6 +71,7 @@ func (api *API) createFrobulator(rw http.ResponseWriter, r *http.Request) { } frob, err := api.Database.InsertFrobulator(ctx, database.InsertFrobulatorParams{ + ID: uuid.New(), UserID: member.UserID, OrgID: org.ID, ModelNumber: req.ModelNumber, From de8f1820d60a726e7004c8c050b97c2a2e16fb80 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 09:25:56 +0100 Subject: [PATCH 14/16] update tests for GetFrobulators --- coderd/frobulators_test.go | 75 ++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/coderd/frobulators_test.go b/coderd/frobulators_test.go index f9c941aa9155d..96b320d52e83d 100644 --- a/coderd/frobulators_test.go +++ b/coderd/frobulators_test.go @@ -119,37 +119,37 @@ func TestFrobulators(t *testing.T) { t.Parallel() tests := []struct { - name string - member codersdk.User - memberOrg database.Organization - adminOrg database.Organization - role rbac.RoleIdentifier - expectedFrob uuid.UUID - expectedErr string + name string + member codersdk.User + memberOrg database.Organization + adminOrg database.Organization + role rbac.RoleIdentifier + expectedFrobIDs []uuid.UUID + expectedErr string }{ { - name: "owner, same org", - member: member1, - memberOrg: org1, - adminOrg: org1, - role: rbac.RoleOwner(), - expectedFrob: frobulatorID, + name: "owner, same org", + member: member1, + memberOrg: org1, + adminOrg: org1, + role: rbac.RoleOwner(), + expectedFrobIDs: []uuid.UUID{frobulatorID}, }, { - name: "owner, different org", - member: member2, - memberOrg: org2, - adminOrg: org1, - role: rbac.RoleOwner(), - expectedFrob: frobulator2ID, + name: "owner, different org", + member: member2, + memberOrg: org2, + adminOrg: org1, + role: rbac.RoleOwner(), + expectedFrobIDs: []uuid.UUID{frobulator2ID}, }, { - name: "org admin, same org", - member: member2, - memberOrg: org2, - adminOrg: org2, - role: rbac.ScopedRoleOrgAdmin(org2.ID), - expectedFrob: frobulator2ID, + name: "org admin, same org", + member: member2, + memberOrg: org2, + adminOrg: org2, + role: rbac.ScopedRoleOrgAdmin(org2.ID), + expectedFrobIDs: []uuid.UUID{frobulator2ID}, }, { // Org admins do not have permission outside of their own org. @@ -162,12 +162,13 @@ func TestFrobulators(t *testing.T) { }, { // User admins do not have permissions even inside their own org. - name: "user admin, same org", - member: member2, - memberOrg: org2, - adminOrg: org2, - role: rbac.ScopedRoleOrgUserAdmin(org2.ID), - expectedErr: "500: An internal server error occurred", + // They will simply not see any frobulators to which they do not have access. + name: "user admin, same org", + member: member2, + memberOrg: org2, + adminOrg: org2, + role: rbac.ScopedRoleOrgUserAdmin(org2.ID), + expectedFrobIDs: []uuid.UUID{}, }, } @@ -187,17 +188,13 @@ func TestFrobulators(t *testing.T) { return } - // Then: the expected frobulator should be returned + // Otherwise: the expected frobulator(s) should be returned require.NoError(t, err) - require.Len(t, frobs, 1) - - var found bool + actualFrobIDs := make([]uuid.UUID, 0, len(frobs)) for _, f := range frobs { - if f.ID == tc.expectedFrob { - found = true - } + actualFrobIDs = append(actualFrobIDs, f.ID) } - require.True(t, found, "reference frobulator not found") + require.ElementsMatch(t, tc.expectedFrobIDs, actualFrobIDs, "expected frobulator IDs not found") }) } }) From 47d947756bc50e3b392540041807269a84739e38 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 9 Sep 2024 09:27:36 +0100 Subject: [PATCH 15/16] undiff --- coderd/coderd.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index db2388d3c0f78..82dad1443d7b5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,11 +37,10 @@ import ( "tailscale.com/util/singleflight" "cdr.dev/slog" - "github.com/coder/quartz" - "github.com/coder/serpent" - "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/idpsync" + "github.com/coder/quartz" + "github.com/coder/serpent" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/buildinfo" From 26d36eac9301d00077b7ff6e0d09aa80de9591cd Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 10 Sep 2024 11:12:31 +0200 Subject: [PATCH 16/16] Fix migration Signed-off-by: Danny Kopping --- ...{000249_frobulations.down.sql => 000250_frobulations.down.sql} | 0 .../{000249_frobulations.up.sql => 000250_frobulations.up.sql} | 0 .../{000249_frobulations.up.sql => 000250_frobulations.up.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000249_frobulations.down.sql => 000250_frobulations.down.sql} (100%) rename coderd/database/migrations/{000249_frobulations.up.sql => 000250_frobulations.up.sql} (100%) rename coderd/database/migrations/testdata/fixtures/{000249_frobulations.up.sql => 000250_frobulations.up.sql} (100%) diff --git a/coderd/database/migrations/000249_frobulations.down.sql b/coderd/database/migrations/000250_frobulations.down.sql similarity index 100% rename from coderd/database/migrations/000249_frobulations.down.sql rename to coderd/database/migrations/000250_frobulations.down.sql diff --git a/coderd/database/migrations/000249_frobulations.up.sql b/coderd/database/migrations/000250_frobulations.up.sql similarity index 100% rename from coderd/database/migrations/000249_frobulations.up.sql rename to coderd/database/migrations/000250_frobulations.up.sql diff --git a/coderd/database/migrations/testdata/fixtures/000249_frobulations.up.sql b/coderd/database/migrations/testdata/fixtures/000250_frobulations.up.sql similarity index 100% rename from coderd/database/migrations/testdata/fixtures/000249_frobulations.up.sql rename to coderd/database/migrations/testdata/fixtures/000250_frobulations.up.sql