diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 494eab58e4784..b0154571f07b9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2760,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": [ @@ -10279,6 +10413,26 @@ const docTemplate = `{ } } }, + "codersdk.Frobulator": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "model_number": { + "type": "string" + }, + "org_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.GenerateAPIKeyResponse": { "type": "object", "properties": { @@ -10404,6 +10558,14 @@ const docTemplate = `{ } } }, + "codersdk.InsertFrobulatorRequest": { + "type": "object", + "properties": { + "model_number": { + "type": "string" + } + } + }, "codersdk.InsightsReportInterval": { "type": "string", "enum": [ @@ -11731,6 +11893,7 @@ const docTemplate = `{ "deployment_config", "deployment_stats", "file", + "frobulator", "group", "group_member", "license", @@ -11762,6 +11925,7 @@ const docTemplate = `{ "ResourceDeploymentConfig", "ResourceDeploymentStats", "ResourceFile", + "ResourceFrobulator", "ResourceGroup", "ResourceGroupMember", "ResourceLicense", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 54377e00b291e..ddf65c597b9dc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2418,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": [ @@ -9223,6 +9345,26 @@ } } }, + "codersdk.Frobulator": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "model_number": { + "type": "string" + }, + "org_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.GenerateAPIKeyResponse": { "type": "object", "properties": { @@ -9342,6 +9484,14 @@ } } }, + "codersdk.InsertFrobulatorRequest": { + "type": "object", + "properties": { + "model_number": { + "type": "string" + } + } + }, "codersdk.InsightsReportInterval": { "type": "string", "enum": ["day", "week"], @@ -10587,6 +10737,7 @@ "deployment_config", "deployment_stats", "file", + "frobulator", "group", "group_member", "license", @@ -10618,6 +10769,7 @@ "ResourceDeploymentConfig", "ResourceDeploymentStats", "ResourceFile", + "ResourceFrobulator", "ResourceGroup", "ResourceGroupMember", "ResourceLicense", diff --git a/coderd/coderd.go b/coderd/coderd.go index 51b6780e4dc47..6e8c083e0f83e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,11 +37,12 @@ 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/coder/v2/coderd/runtimeconfig" - "github.com/coder/quartz" - "github.com/coder/serpent" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/buildinfo" @@ -938,6 +939,11 @@ 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) + }) }) }) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5782bdc8e7155..454b454df9e47 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1062,6 +1062,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) } @@ -1476,6 +1483,10 @@ 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) { + return fetchWithPostFilter(q.auth, policy.ActionRead, 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) } @@ -2711,6 +2722,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) (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) +} + 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 d23bb48184b61..e15edbc86cf88 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2789,6 +2789,47 @@ func (s *MethodTestSuite) TestNotifications() { })) } +func (s *MethodTestSuite) TestFrobulators() { + s.Run("GetFrobulators", s.Subtest(func(db database.Store, check *expects) { + // 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 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: u1.ID, + OrgID: org.ID, + }).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{}) + org := dbgen.Organization(s.T(), db, database.Organization{}) + check.Args(database.InsertFrobulatorParams{ + UserID: user.ID, + 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) + })) +} + func (s *MethodTestSuite) TestOAuth2ProviderApps() { s.Run("GetOAuth2ProviderApps", s.Subtest(func(db database.Store, check *expects) { apps := []database.OAuth2ProviderApp{ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 79aee59d97dbe..7f10579fe690b 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -893,6 +893,17 @@ 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{ + 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)), + }) + 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 04f0d32537f90..f57e310c96f29 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.GroupMemberTable, 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), @@ -196,6 +197,7 @@ type data struct { workspaceProxies []database.WorkspaceProxy customRoles []database.CustomRole runtimeConfig map[string]string + frobulators []database.Frobulator // Locks is a map of lock names. Any keys within the map are currently // locked. locks map[int64]struct{} @@ -1486,6 +1488,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() @@ -2586,6 +2602,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() @@ -6413,6 +6450,27 @@ func (q *FakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParam return file, nil } +func (q *FakeQuerier) InsertFrobulator(_ context.Context, arg database.InsertFrobulatorParams) (database.Frobulator, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.Frobulator{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + // nolint:gosimple // This is fine as it is. + frob := database.Frobulator{ + ID: uuid.New(), + UserID: arg.UserID, + OrgID: arg.OrgID, + ModelNumber: arg.ModelNumber, + } + q.frobulators = append(q.frobulators, frob) + + return frob, 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 5aa3a0c8d8cfb..0cc1a07161351 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) @@ -634,6 +641,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) @@ -1635,6 +1649,13 @@ func (m metricsStore) InsertFile(ctx context.Context, arg database.InsertFilePar return file, err } +func (m metricsStore) InsertFrobulator(ctx context.Context, arg database.InsertFrobulatorParams) (database.Frobulator, error) { + start := time.Now() + r0, r1 := m.s.InsertFrobulator(ctx, arg) + m.queryLatencies.WithLabelValues("InsertFrobulator").Observe(time.Since(start).Seconds()) + return r0, r1 +} + 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 6d881cfe6fc1b..38f7adfd13b29 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() @@ -1253,6 +1267,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() @@ -3439,6 +3468,21 @@ 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) (database.Frobulator, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertFrobulator", arg0, arg1) + ret0, _ := ret[0].(database.Frobulator) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// 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 6638d52745ba6..b4f8d858533ba 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -561,6 +561,13 @@ CREATE TABLE files ( id uuid DEFAULT gen_random_uuid() NOT NULL ); +CREATE TABLE frobulators ( + id uuid NOT NULL, + user_id uuid NOT NULL, + org_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, @@ -1658,6 +1665,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); @@ -2035,6 +2048,12 @@ CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE O 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; + 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 0c578255f091c..2daeb97543dfb 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -7,6 +7,8 @@ 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); 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/000250_frobulations.down.sql b/coderd/database/migrations/000250_frobulations.down.sql new file mode 100644 index 0000000000000..eaf995590b5f2 --- /dev/null +++ b/coderd/database/migrations/000250_frobulations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS frobulators; diff --git a/coderd/database/migrations/000250_frobulations.up.sql b/coderd/database/migrations/000250_frobulations.up.sql new file mode 100644 index 0000000000000..b4a1b90f54ff7 --- /dev/null +++ b/coderd/database/migrations/000250_frobulations.up.sql @@ -0,0 +1,11 @@ +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 (org_id) REFERENCES organizations (id) ON DELETE CASCADE +); diff --git a/coderd/database/migrations/testdata/fixtures/000250_frobulations.up.sql b/coderd/database/migrations/testdata/fixtures/000250_frobulations.up.sql new file mode 100644 index 0000000000000..0c95ef7c1153d --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000250_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 diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 816fc4c9214b0..cc6b42db2456d 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -305,6 +305,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 9e0283ba859c1..e1b6f2479eb2d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2098,6 +2098,13 @@ 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"` + OrgID uuid.UUID `db:"org_id" json:"org_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 3432bac7dada1..5426975c06219 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 @@ -145,6 +146,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) @@ -343,6 +345,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) (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 89822a72a7855..b576e1291d16a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1191,6 +1191,91 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } +const deleteFrobulator = `-- name: DeleteFrobulator :exec +DELETE FROM frobulators +WHERE id = $1 AND user_id = $2 AND org_id = $3 +` + +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"` +} + +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 AND org_id = $2 +` + +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 + } + defer rows.Close() + var items []Frobulator + for rows.Next() { + var i Frobulator + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.OrgID, + &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 :one +INSERT INTO frobulators (id, user_id, org_id, model_number) +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.ID, + 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 DELETE FROM gitsshkeys diff --git a/coderd/database/queries/frobulators.sql b/coderd/database/queries/frobulators.sql new file mode 100644 index 0000000000000..d39e0fe02d2aa --- /dev/null +++ b/coderd/database/queries/frobulators.sql @@ -0,0 +1,13 @@ +-- name: GetFrobulators :many +SELECT * +FROM frobulators +WHERE user_id = $1 AND org_id = $2; + +-- name: InsertFrobulator :one +INSERT INTO frobulators (id, user_id, org_id, model_number) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- 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/database/unique_constraint.go b/coderd/database/unique_constraint.go index b3bf72f8178b6..a840355512313 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..5d52d379f2f8a --- /dev/null +++ b/coderd/frobulators.go @@ -0,0 +1,131 @@ +package coderd + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "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/codersdk" +) + +// @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}/members/{user}/frobulators [get] +func (api *API) listFrobulators(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + member := httpmw.OrganizationMemberParam(r) + org := httpmw.OrganizationParam(r) + + frobs, err := api.Database.GetFrobulators(ctx, database.GetFrobulatorsParams{ + UserID: member.UserID, + OrgID: org.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, + OrgID: f.OrgID, + ModelNumber: f.ModelNumber, + }) + } + + 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{ + ID: uuid.New(), + UserID: member.UserID, + 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 + } + + httpapi.Write(r.Context(), rw, http.StatusOK, frob.ID.String()) +} + +// @Summary Delete frobulator +// @ID delete-frobulator +// @Security CoderSessionToken +// @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 204 +// @Router /organizations/{organization}/members/{user}/frobulators/{id} [delete] +func (api *API) deleteFrobulator(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + 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 + } + + member := httpmw.OrganizationMemberParam(r) + org := httpmw.OrganizationParam(r) + + err = api.Database.DeleteFrobulator(ctx, database.DeleteFrobulatorParams{ + ID: id, + 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 + } + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/coderd/frobulators_test.go b/coderd/frobulators_test.go new file mode 100644 index 0000000000000..96b320d52e83d --- /dev/null +++ b/coderd/frobulators_test.go @@ -0,0 +1,231 @@ +package coderd_test + +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" +) + +func TestFrobulators(t *testing.T) { + t.Parallel() + + // Setup for all tests + api, store := coderdtest.NewWithDatabase(t, nil) + + setupCtx := testutil.Context(t, testutil.WaitShort) + 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, member1 := coderdtest.CreateAnotherUser(t, api, org1.ID) + memberClient2, member2 := coderdtest.CreateAnotherUser(t, api, org2.ID) + + 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, 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() + + 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 in the given group + memberClient, _ := coderdtest.CreateAnotherUser(t, api, tc.org.ID) + + // When: attempting to view the frobulators of another user + frobs, err := memberClient.GetFrobulators(ctx, tc.user.ID, tc.org.ID) + + // Then: validate that no frobulators were returned + require.Nil(t, frobs) + + // 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") + require.Equal(t, http.StatusNotFound, 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, org.ID) + + // When: attempting to create a frobulator + 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.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 users' frobulators as admin", func(t *testing.T) { + t.Parallel() + + tests := []struct { + 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(), + expectedFrobIDs: []uuid.UUID{frobulatorID}, + }, + { + 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), + expectedFrobIDs: []uuid.UUID{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. + // 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{}, + }, + } + + 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 + } + + // Otherwise: the expected frobulator(s) should be returned + require.NoError(t, err) + actualFrobIDs := make([]uuid.UUID, 0, len(frobs)) + for _, f := range frobs { + actualFrobIDs = append(actualFrobIDs, f.ID) + } + require.ElementsMatch(t, tc.expectedFrobIDs, actualFrobIDs, "expected frobulator IDs not found") + }) + } + }) + + t.Run("Create and delete 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, org.ID) + + // When: attempting to create a frobulator + 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.GetFrobulators(ctx, member.ID, org.ID) + require.NoError(t, err) + require.Len(t, frobs, 1) + require.Equal(t, id, frobs[0].ID) + + // 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 d270fdad5c1bd..e0e87c704293d 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -85,6 +85,16 @@ var ( Type: "file", } + // ResourceFrobulator + // Valid Actions + // - "ActionCreate" :: create a frobulator + // - "ActionDelete" :: delete a frobulator + // - "ActionRead" :: read a frobulator + // - "ActionUpdate" :: update a frobulator + ResourceFrobulator = Object{ + Type: "frobulator", + } + // ResourceGroup // Valid Actions // - "ActionCreate" :: create a group @@ -295,6 +305,7 @@ func AllResources() []Objecter { ResourceDeploymentConfig, ResourceDeploymentStats, ResourceFile, + ResourceFrobulator, ResourceGroup, ResourceGroupMember, ResourceLicense, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f71a400890a41..6e74f41826604 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -274,4 +274,12 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: actDef("update notification preferences"), }, }, + "frobulator": { + Actions: map[Action]ActionDefinition{ + ActionCreate: {Description: "create a frobulator"}, + ActionRead: {Description: "read a frobulator"}, + ActionUpdate: {Description: "update a frobulator"}, + ActionDelete: {Description: "delete a frobulator"}, + }, + }, } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index db62bbd6e6d0d..7aee8d1ddc2c1 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -311,6 +311,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{}, @@ -443,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 a68132ec76ed3..530ff72059018 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -665,6 +665,54 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // 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 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, orgAdmin}, + false: {memberMe, orgMemberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + }, + }, + { + // 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.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}, + }, + }, // AnyOrganization tests { Name: "CreateOrgMember", diff --git a/codersdk/frobulators.go b/codersdk/frobulators.go new file mode 100644 index 0000000000000..f4bef011fc490 --- /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"` + OrgID uuid.UUID `json:"org_id" format:"uuid"` + ModelNumber string `json:"model_number"` +} + +type InsertFrobulatorRequest struct { + ModelNumber string `json:"model_number"` +} + +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/organizations/%s/members/%s/frobulators", orgID.String(), 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) GetFrobulators(ctx context.Context, userID, orgID uuid.UUID) ([]Frobulator, error) { + 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 + } + 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) DeleteFrobulator(ctx context.Context, id, userID, orgID uuid.UUID) error { + 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.StatusNoContent { + return ReadBodyAsError(res) + } + + return nil +} diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 820d4f31b27a7..95b0dfc827143 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" ResourceGroupMember RBACResource = "group_member" ResourceLicense RBACResource = "license" @@ -65,6 +66,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceDeploymentConfig: {ActionRead, ActionUpdate}, ResourceDeploymentStats: {ActionRead}, ResourceFile: {ActionCreate, ActionRead}, + ResourceFrobulator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceGroupMember: {ActionRead}, ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, diff --git a/docs/manifest.json b/docs/manifest.json index eb7d2b576b555..f60e19be3419d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -611,6 +611,10 @@ "title": "Files", "path": "./reference/api/files.md" }, + { + "title": "Frobulator", + "path": "./api/frobulator.md" + }, { "title": "Git", "path": "./reference/api/git.md" diff --git a/docs/reference/api/frobulator.md b/docs/reference/api/frobulator.md new file mode 100644 index 0000000000000..46c01a9ef37ec --- /dev/null +++ b/docs/reference/api/frobulator.md @@ -0,0 +1,121 @@ +# Frobulator + +## Get frobulators + +### Code samples + +```shell +# Example request using curl +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 /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 + +> 200 Response + +```json +[ + { + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "model_number": "string", + "org_id": "a40f5d1f-d889-42e9-94ea-b9b33585fc6b", + "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) | + +