From cbe24a3da587bd959a949d8472f0fa7f8bdeeece Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:43:45 +0100 Subject: [PATCH] feat: add chat model config RBAC resource (#27957) Add the `chat_model_config` RBAC resource, actions, and API key scopes. This layer includes the scope migration and generated API types. Model list queries apply token scopes, SQL authorization filters, and row ACLs. Write and default-management handlers keep deployment configuration authorization until the next API layer replaces it. Depends on #27956 _This pull request description was generated by Coder Agents._ --- coderd/apidoc/docs.go | 16 ++ coderd/apidoc/swagger.json | 16 ++ coderd/database/dbauthz/dbauthz.go | 70 ++++- coderd/database/dbauthz/dbauthz_test.go | 143 +++++++++- coderd/database/dbmetrics/querymetrics.go | 24 ++ coderd/database/dbmock/dbmock.go | 45 +++ coderd/database/dump.sql | 11 +- .../000581_chat_model_config_scopes.down.sql | 2 + .../000581_chat_model_config_scopes.up.sql | 6 + .../000582_audit_chat_model_config.down.sql | 1 + .../000582_audit_chat_model_config.up.sql | 2 + coderd/database/modelqueries.go | 67 +++++ coderd/database/models.go | 25 +- coderd/database/querier.go | 1 + coderd/database/querier_test.go | 77 ++++++ coderd/database/queries.sql.go | 55 ++++ coderd/database/queries/chatmodelconfigs.sql | 16 ++ coderd/exp_chats.go | 56 ++-- coderd/exp_chats_model_config_list_test.go | 256 ++++++++++++++++++ coderd/exp_chats_test.go | 199 ++++++++++---- coderd/rbac/authz.go | 8 + coderd/rbac/object_gen.go | 12 + coderd/rbac/policy/policy.go | 11 + coderd/rbac/regosql/compile_test.go | 46 ++++ coderd/rbac/regosql/configs.go | 17 ++ coderd/rbac/roles.go | 2 + coderd/rbac/roles_test.go | 29 ++ coderd/rbac/scopes_catalog.go | 4 + coderd/rbac/scopes_catalog_internal_test.go | 1 + coderd/rbac/scopes_constants_gen.go | 15 + coderd/x/chatd/chatd.go | 2 +- codersdk/apikey_scopes_gen.go | 8 + codersdk/audit.go | 3 + codersdk/rbacresources_gen.go | 2 + docs/reference/api/members.md | 40 +-- docs/reference/api/schemas.md | 18 +- docs/reference/api/users.md | 10 +- site/src/api/rbacresourcesGenerated.ts | 7 + site/src/api/typesGenerated.ts | 16 ++ 39 files changed, 1190 insertions(+), 149 deletions(-) create mode 100644 coderd/database/migrations/000581_chat_model_config_scopes.down.sql create mode 100644 coderd/database/migrations/000581_chat_model_config_scopes.up.sql create mode 100644 coderd/database/migrations/000582_audit_chat_model_config.down.sql create mode 100644 coderd/database/migrations/000582_audit_chat_model_config.up.sql create mode 100644 coderd/exp_chats_model_config_list_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a8f25d7cce4..baad1b12691 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16594,6 +16594,12 @@ const docTemplate = `{ "chat:read", "chat:share", "chat:update", + "chat_model_config:*", + "chat_model_config:create", + "chat_model_config:delete", + "chat_model_config:read", + "chat_model_config:share", + "chat_model_config:update", "coder:all", "coder:apikeys.manage_self", "coder:application_connect", @@ -16840,6 +16846,12 @@ const docTemplate = `{ "APIKeyScopeChatRead", "APIKeyScopeChatShare", "APIKeyScopeChatUpdate", + "APIKeyScopeChatModelConfigAll", + "APIKeyScopeChatModelConfigCreate", + "APIKeyScopeChatModelConfigDelete", + "APIKeyScopeChatModelConfigRead", + "APIKeyScopeChatModelConfigShare", + "APIKeyScopeChatModelConfigUpdate", "APIKeyScopeCoderAll", "APIKeyScopeCoderApikeysManageSelf", "APIKeyScopeCoderApplicationConnect", @@ -24632,6 +24644,7 @@ const docTemplate = `{ "boundary_log", "boundary_usage", "chat", + "chat_model_config", "connection_log", "crypto_key", "debug_info", @@ -24686,6 +24699,7 @@ const docTemplate = `{ "ResourceBoundaryLog", "ResourceBoundaryUsage", "ResourceChat", + "ResourceChatModelConfig", "ResourceConnectionLog", "ResourceCryptoKey", "ResourceDebugInfo", @@ -24944,6 +24958,7 @@ const docTemplate = `{ "user_ai_budget_override", "chat", "mcp_server_config", + "chat_model_config", "user_secret", "user_skill", "chat_instruction_settings" @@ -24984,6 +24999,7 @@ const docTemplate = `{ "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeMCPServerConfig", + "ResourceTypeChatModelConfig", "ResourceTypeUserSecret", "ResourceTypeUserSkill", "ResourceTypeChatInstructionSettings" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 29f75031056..cea51e0dcca 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14844,6 +14844,12 @@ "chat:read", "chat:share", "chat:update", + "chat_model_config:*", + "chat_model_config:create", + "chat_model_config:delete", + "chat_model_config:read", + "chat_model_config:share", + "chat_model_config:update", "coder:all", "coder:apikeys.manage_self", "coder:application_connect", @@ -15090,6 +15096,12 @@ "APIKeyScopeChatRead", "APIKeyScopeChatShare", "APIKeyScopeChatUpdate", + "APIKeyScopeChatModelConfigAll", + "APIKeyScopeChatModelConfigCreate", + "APIKeyScopeChatModelConfigDelete", + "APIKeyScopeChatModelConfigRead", + "APIKeyScopeChatModelConfigShare", + "APIKeyScopeChatModelConfigUpdate", "APIKeyScopeCoderAll", "APIKeyScopeCoderApikeysManageSelf", "APIKeyScopeCoderApplicationConnect", @@ -22578,6 +22590,7 @@ "boundary_log", "boundary_usage", "chat", + "chat_model_config", "connection_log", "crypto_key", "debug_info", @@ -22632,6 +22645,7 @@ "ResourceBoundaryLog", "ResourceBoundaryUsage", "ResourceChat", + "ResourceChatModelConfig", "ResourceConnectionLog", "ResourceCryptoKey", "ResourceDebugInfo", @@ -22880,6 +22894,7 @@ "user_ai_budget_override", "chat", "mcp_server_config", + "chat_model_config", "user_secret", "user_skill", "chat_instruction_settings" @@ -22920,6 +22935,7 @@ "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", "ResourceTypeMCPServerConfig", + "ResourceTypeChatModelConfig", "ResourceTypeUserSecret", "ResourceTypeUserSkill", "ResourceTypeChatInstructionSettings" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 68eab17b701..d3b15f8374e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -816,6 +816,7 @@ var ( Site: rbac.Permissions(map[string][]policy.Action{ rbac.ResourceAIProvider.Type: {policy.ActionRead}, rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + rbac.ResourceChatModelConfig.Type: {policy.ActionRead}, rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, rbac.ResourceMCPServerConfig.Type: {policy.ActionRead}, @@ -2221,6 +2222,8 @@ func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database. } func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + // Interim write gate: see InsertChatModelConfig. + // TODO(mafredri): swap to ResourceChatModelConfig object delete after CODAGT-709 M3 (org-scoping cutover) if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return uuid.Nil, err } @@ -3509,6 +3512,8 @@ func (q *querier) GetChatMessagesForPromptByChatID(ctx context.Context, chatID u } func (q *querier) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { + // Interim read gate: see InsertChatModelConfig. + // TODO(mafredri): swap to ResourceChatModelConfig object read after CODAGT-709 M3 (org-scoping cutover) if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return database.ChatModelConfig{}, err } @@ -3516,10 +3521,11 @@ func (q *querier) GetChatModelConfigByID(ctx context.Context, id uuid.UUID) (dat } func (q *querier) GetChatModelConfigs(ctx context.Context) ([]database.ChatModelConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceChatModelConfig.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) } - return q.db.GetChatModelConfigs(ctx) + return q.db.GetAuthorizedChatModelConfigs(ctx, prep) } func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]database.GetChatModelConfigsForTelemetryRow, error) { @@ -3755,12 +3761,8 @@ func (q *querier) GetDatabaseNow(ctx context.Context) (time.Time, error) { } func (q *querier) GetDefaultChatModelConfig(ctx context.Context, organizationID uuid.UUID) (database.ChatModelConfig, error) { - // Reading the default model config is needed for chat creation. - // TODO(CODAGT-161): scope this check when org context is available. - // This function has no org context to scope the check, and - // ResourceDeploymentConfig is too restrictive (admin-only). - // The handler layer gates chat creation via ActionCreate on - // the org-scoped ResourceChat. + // Chat creation authorizes the organization-scoped chat before this lookup. + // TODO(mafredri): remove after CODAGT-709 M3. if _, ok := ActorFromContext(ctx); !ok { return database.ChatModelConfig{}, ErrNoActor } @@ -3800,22 +3802,25 @@ func (q *querier) GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx context.C } func (q *querier) GetEnabledChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + // TODO(mafredri): authorize the fetched object after CODAGT-709 M3 + // (org-scoping cutover). + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil { return database.ChatModelConfig{}, err } return q.db.GetEnabledChatModelConfigByID(ctx, id) } func (q *querier) GetEnabledChatModelConfigs(ctx context.Context) ([]database.GetEnabledChatModelConfigsRow, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + // TODO(mafredri): replace this deployment-wide query after CODAGT-709 M3 + // (org-scoping cutover). + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil { return nil, err } return q.db.GetEnabledChatModelConfigs(ctx) } func (q *querier) GetEnabledChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.GetEnabledChatModelConfigsByOrganizationRow, error) { - // TODO(CODAGT-161): authorize against an organization-scoped chat model config resource. - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(organizationID)); err != nil { return nil, err } return q.db.GetEnabledChatModelConfigsByOrganization(ctx, organizationID) @@ -6111,6 +6116,9 @@ func (q *querier) InsertChatMessages(ctx context.Context, arg database.InsertCha } func (q *querier) InsertChatModelConfig(ctx context.Context, arg database.InsertChatModelConfigParams) (database.ChatModelConfig, error) { + // The routes create configs in the default organization. Organization + // administrators must not manage configs that other organizations can read. + // TODO(mafredri): swap to ResourceChatModelConfig create-in-org after CODAGT-709 M3 (org-scoping cutover) if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.ChatModelConfig{}, err } @@ -7314,7 +7322,9 @@ func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error { } func (q *querier) UnsetDefaultChatModelConfigs(ctx context.Context, organizationID uuid.UUID) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + // Interim write gate: see InsertChatModelConfig. + // TODO(mafredri): swap to ResourceChatModelConfig update-in-org after CODAGT-709 M3 (org-scoping cutover) + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err } return q.db.UnsetDefaultChatModelConfigs(ctx, organizationID) @@ -7493,12 +7503,30 @@ func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.Updat } func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { + // Interim write gate: see InsertChatModelConfig. + // TODO(mafredri): swap to ResourceChatModelConfig object update after CODAGT-709 M3 (org-scoping cutover) if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.ChatModelConfig{}, err } return q.db.UpdateChatModelConfig(ctx, arg) } +func (q *querier) UpdateChatModelConfigACLByID(ctx context.Context, arg database.UpdateChatModelConfigACLByIDParams) (database.ChatModelConfig, error) { + config, err := q.db.GetChatModelConfigByID(ctx, arg.ID) + if err != nil { + return database.ChatModelConfig{}, err + } + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + if err := q.authorizeContext(ctx, policy.ActionShare, object); err != nil { + return database.ChatModelConfig{}, err + } + return q.db.UpdateChatModelConfigACLByID(ctx, arg) +} + func (q *querier) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error { chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { @@ -9450,6 +9478,20 @@ func (q *querier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID uui return q.db.GetAuthorizedChatsByChatFileID(ctx, fileID, prepared) } +func (q *querier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { + return q.db.GetAuthorizedChatModelConfigs(ctx, prepared) +} + +// GetDefaultChatModelConfigCandidates returns all non-deleted configs. +// A filtered result can leave an organization without a default. +// TODO(mafredri): remove after CODAGT-709 M3. +func (q *querier) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return nil, err + } + return q.db.GetDefaultChatModelConfigCandidates(ctx) +} + func (q *querier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { return q.db.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 83b7c150b20..e5b7766b19c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -276,6 +276,105 @@ func TestUpdateChatACLByIDGuards(t *testing.T) { }) } +func TestUpdateChatModelConfigACLByIDAuthorization(t *testing.T) { + t.Parallel() + + organizationID := uuid.New() + otherOrganizationID := uuid.New() + readerID := uuid.New() + config := database.ChatModelConfig{ + ID: uuid.New(), + OrganizationID: organizationID, + UserACL: database.ChatACL{ + readerID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + GroupACL: database.ChatACL{}, + } + arg := database.UpdateChatModelConfigACLByIDParams{ + ID: config.ID, + GroupACL: database.ChatACL{}, + UserACL: database.ChatACL{}, + UpdatedBy: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + updated := config + updated.GroupACL = arg.GroupACL + updated.UserACL = arg.UserACL + updated.UpdatedBy = arg.UpdatedBy + + orgAdminSubject := func(id uuid.UUID, orgID uuid.UUID) rbac.Subject { + return rbac.Subject{ + ID: id.String(), + Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.ScopedRoleOrgAdmin(orgID)}.Expand())), + Groups: []string{}, + Scope: rbac.ExpandableScope(rbac.ScopeAll), + } + } + readerSubject := rbac.Subject{ + ID: readerID.String(), + Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleMember()}.Expand())), + Groups: []string{}, + Scope: rbac.ExpandableScope(rbac.ScopeAll), + } + + testCases := []struct { + name string + subject rbac.Subject + fetchError error + allowed bool + }{ + { + name: "Allowed", + subject: orgAdminSubject(uuid.New(), organizationID), + allowed: true, + }, + { + name: "DeniedReadOnlyACL", + subject: readerSubject, + }, + { + name: "HiddenRow", + subject: orgAdminSubject(uuid.New(), organizationID), + fetchError: sql.ErrNoRows, + }, + { + name: "CrossOrganization", + subject: orgAdminSubject(uuid.New(), otherOrganizationID), + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + db.EXPECT().Wrappers().Return([]string{}).AnyTimes() + db.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, testCase.fetchError) + if testCase.allowed { + db.EXPECT().UpdateChatModelConfigACLByID(gomock.Any(), arg).Return(updated, nil) + } + + q := dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }), coderdtest.AccessControlStorePointer()) + got, err := q.UpdateChatModelConfigACLByID(dbauthz.As(t.Context(), testCase.subject), arg) + + switch { + case testCase.allowed: + require.NoError(t, err) + require.Equal(t, updated, got) + case testCase.fetchError != nil: + require.ErrorIs(t, err, testCase.fetchError) + require.Equal(t, database.ChatModelConfig{}, got) + default: + require.Error(t, err) + require.True(t, dbauthz.IsNotAuthorizedError(err), "expected authorization failure, got: %v", err) + require.Equal(t, database.ChatModelConfig{}, got) + } + }) + } +} + // TestDBAuthzRecursive is a simple test to search for infinite recursion // bugs. It isn't perfect, and only catches a subset of the possible bugs // as only the first db call will be made. But it is better than nothing. @@ -1083,10 +1182,20 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any(), config.OrganizationID).Return(config, nil).AnyTimes() check.Args(config.OrganizationID).Asserts().Returns(config) })) - s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args().Asserts() + })) + s.Run("GetAuthorizedChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetAuthorizedChatModelConfigs(gomock.Any(), gomock.Any()).Return([]database.ChatModelConfig{}, nil).AnyTimes() + // No asserts here because callers provide the SQL filter. + check.Args(emptyPreparedAuthorized{}).Asserts() + })) + s.Run("GetDefaultChatModelConfigCandidates", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) - dbm.EXPECT().GetChatModelConfigs(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes() + dbm.EXPECT().GetDefaultChatModelConfigCandidates(gomock.Any()).Return([]database.ChatModelConfig{configA, configB}, nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.ChatModelConfig{configA, configB}) })) @@ -1194,13 +1303,13 @@ func (s *MethodTestSuite) TestChats() { s.Run("GetEnabledChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) dbm.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() - check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config) + check.Args(config.ID).Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns(config) })) s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{}) rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsRow{}) dbm.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{rowA, rowB}, nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsRow{rowA, rowB}) + check.Args().Asserts(rbac.ResourceChatModelConfig, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsRow{rowA, rowB}) })) s.Run("GetEnabledChatModelConfigsByOrganization", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -1208,7 +1317,7 @@ func (s *MethodTestSuite) TestChats() { rowA := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{}) rowB := testutil.Fake(s.T(), faker, database.GetEnabledChatModelConfigsByOrganizationRow{}) dbm.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), orgID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}, nil).AnyTimes() - check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}) + check.Args(orgID).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionRead).Returns([]database.GetEnabledChatModelConfigsByOrganizationRow{rowA, rowB}) })) s.Run("GetStaleChats", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { @@ -1489,6 +1598,28 @@ func (s *MethodTestSuite) TestChats() { check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) })) + s.Run("UpdateChatModelConfigACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) + arg := database.UpdateChatModelConfigACLByIDParams{ + ID: config.ID, + GroupACL: database.ChatACL{}, + UserACL: database.ChatACL{}, + UpdatedBy: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + updated := config + updated.GroupACL = arg.GroupACL + updated.UserACL = arg.UserACL + updated.UpdatedBy = arg.UpdatedBy + dbm.EXPECT().GetChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + dbm.EXPECT().UpdateChatModelConfigACLByID(gomock.Any(), arg).Return(updated, nil).AnyTimes() + object := rbac.ResourceChatModelConfig. + WithID(config.ID). + InOrg(config.OrganizationID). + WithACLUserList(config.UserACL.RBACACL()). + WithGroupACL(config.GroupACL.RBACACL()) + check.Args(arg).Asserts(object, policy.ActionShare).Returns(updated) + })) + s.Run("UpdateChatPinOrder", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatPinOrderParams{ @@ -1537,7 +1668,7 @@ func (s *MethodTestSuite) TestChats() { s.Run("UnsetDefaultChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() dbm.EXPECT().UnsetDefaultChatModelConfigs(gomock.Any(), orgID).Return(nil).AnyTimes() - check.Args(orgID).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + check.Args(orgID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) s.Run("UpsertChatDiffStatus", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c697cbf2a66..33f5560ada7 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5320,6 +5320,14 @@ func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg databa return r0, r1 } +func (m queryMetricsStore) UpdateChatModelConfigACLByID(ctx context.Context, arg database.UpdateChatModelConfigACLByIDParams) (database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatModelConfigACLByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatModelConfigACLByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatModelConfigACLByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error { start := time.Now() r0 := m.s.UpdateChatPinOrder(ctx, arg) @@ -6832,6 +6840,22 @@ func (m queryMetricsStore) GetAuthorizedChatsByChatFileID(ctx context.Context, f return r0, r1 } +func (m queryMetricsStore) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedChatModelConfigs(ctx, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedChatModelConfigs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAuthorizedChatModelConfigs").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) { + start := time.Now() + r0, r1 := m.s.GetDefaultChatModelConfigCandidates(ctx) + m.queryLatencies.WithLabelValues("GetDefaultChatModelConfigCandidates").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetDefaultChatModelConfigCandidates").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.MCPServerConfig, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedMCPServerConfigs(ctx, organizationID, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e1765317489..856f1ba49a2 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2475,6 +2475,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(ctx, arg, prepared return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedAuditLogsOffset", reflect.TypeOf((*MockStore)(nil).GetAuthorizedAuditLogsOffset), ctx, arg, prepared) } +// GetAuthorizedChatModelConfigs mocks base method. +func (m *MockStore) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedChatModelConfigs", ctx, prepared) + ret0, _ := ret[0].([]database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedChatModelConfigs indicates an expected call of GetAuthorizedChatModelConfigs. +func (mr *MockStoreMockRecorder) GetAuthorizedChatModelConfigs(ctx, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedChatModelConfigs", reflect.TypeOf((*MockStore)(nil).GetAuthorizedChatModelConfigs), ctx, prepared) +} + // GetAuthorizedChats mocks base method. func (m *MockStore) GetAuthorizedChats(ctx context.Context, arg database.GetChatsParams, prepared rbac.PreparedAuthorized) ([]database.GetChatsRow, error) { m.ctrl.T.Helper() @@ -3630,6 +3645,21 @@ func (mr *MockStoreMockRecorder) GetDefaultChatModelConfig(ctx, organizationID a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfig", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfig), ctx, organizationID) } +// GetDefaultChatModelConfigCandidates mocks base method. +func (m *MockStore) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDefaultChatModelConfigCandidates", ctx) + ret0, _ := ret[0].([]database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDefaultChatModelConfigCandidates indicates an expected call of GetDefaultChatModelConfigCandidates. +func (mr *MockStoreMockRecorder) GetDefaultChatModelConfigCandidates(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultChatModelConfigCandidates", reflect.TypeOf((*MockStore)(nil).GetDefaultChatModelConfigCandidates), ctx) +} + // GetDefaultOrganization mocks base method. func (m *MockStore) GetDefaultOrganization(ctx context.Context) (database.Organization, error) { m.ctrl.T.Helper() @@ -10073,6 +10103,21 @@ func (mr *MockStoreMockRecorder) UpdateChatModelConfig(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatModelConfig", reflect.TypeOf((*MockStore)(nil).UpdateChatModelConfig), ctx, arg) } +// UpdateChatModelConfigACLByID mocks base method. +func (m *MockStore) UpdateChatModelConfigACLByID(ctx context.Context, arg database.UpdateChatModelConfigACLByIDParams) (database.ChatModelConfig, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatModelConfigACLByID", ctx, arg) + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatModelConfigACLByID indicates an expected call of UpdateChatModelConfigACLByID. +func (mr *MockStoreMockRecorder) UpdateChatModelConfigACLByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatModelConfigACLByID", reflect.TypeOf((*MockStore)(nil).UpdateChatModelConfigACLByID), ctx, arg) +} + // UpdateChatPinOrder mocks base method. func (m *MockStore) UpdateChatPinOrder(ctx context.Context, arg database.UpdateChatPinOrderParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index caf363b1eff..ebb9508f58d 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -284,7 +284,13 @@ CREATE TYPE api_key_scope AS ENUM ( 'mcp_server_config:read', 'mcp_server_config:update', 'mcp_server_config:delete', - 'mcp_server_config:share' + 'mcp_server_config:share', + 'chat_model_config:*', + 'chat_model_config:create', + 'chat_model_config:read', + 'chat_model_config:update', + 'chat_model_config:delete', + 'chat_model_config:share' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -611,7 +617,8 @@ CREATE TYPE resource_type AS ENUM ( 'user_ai_budget_override', 'oauth2_provider_settings', 'chat_instruction_settings', - 'mcp_server_config' + 'mcp_server_config', + 'chat_model_config' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000581_chat_model_config_scopes.down.sql b/coderd/database/migrations/000581_chat_model_config_scopes.down.sql new file mode 100644 index 00000000000..04f101ceb4e --- /dev/null +++ b/coderd/database/migrations/000581_chat_model_config_scopes.down.sql @@ -0,0 +1,2 @@ +-- Enum additions to api_key_scope are intentionally not reverted because +-- Postgres cannot drop enum values safely. diff --git a/coderd/database/migrations/000581_chat_model_config_scopes.up.sql b/coderd/database/migrations/000581_chat_model_config_scopes.up.sql new file mode 100644 index 00000000000..26b8c06ba5b --- /dev/null +++ b/coderd/database/migrations/000581_chat_model_config_scopes.up.sql @@ -0,0 +1,6 @@ +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_model_config:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_model_config:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_model_config:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_model_config:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_model_config:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'chat_model_config:share'; diff --git a/coderd/database/migrations/000582_audit_chat_model_config.down.sql b/coderd/database/migrations/000582_audit_chat_model_config.down.sql new file mode 100644 index 00000000000..35020b349fc --- /dev/null +++ b/coderd/database/migrations/000582_audit_chat_model_config.down.sql @@ -0,0 +1 @@ +-- No-op, enum values can't be dropped. diff --git a/coderd/database/migrations/000582_audit_chat_model_config.up.sql b/coderd/database/migrations/000582_audit_chat_model_config.up.sql new file mode 100644 index 00000000000..0a35ec2a59f --- /dev/null +++ b/coderd/database/migrations/000582_audit_chat_model_config.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE resource_type + ADD VALUE IF NOT EXISTS 'chat_model_config'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index c9df9f980f5..663c0a97e92 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -53,9 +53,76 @@ type customQuerier interface { connectionLogQuerier aibridgeQuerier chatQuerier + chatModelConfigQuerier mcpServerConfigQuerier } +type chatModelConfigQuerier interface { + GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) + // GetDefaultChatModelConfigCandidates returns every non-deleted config in + // every organization without an authorization filter. The caller selects + // one organization's promotion candidate from this deployment-wide list. + GetDefaultChatModelConfigCandidates(ctx context.Context) ([]ChatModelConfig, error) +} + +func (q *sqlQuerier) GetDefaultChatModelConfigCandidates(ctx context.Context) ([]ChatModelConfig, error) { + return q.GetChatModelConfigs(ctx) +} + +func (q *sqlQuerier) GetAuthorizedChatModelConfigs(ctx context.Context, prepared rbac.PreparedAuthorized) ([]ChatModelConfig, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigChatModelConfigs()) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + filtered, err := insertAuthorizedFilter(getChatModelConfigs, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + // The name comment is for metric tracking + query := fmt.Sprintf("-- name: GetAuthorizedChatModelConfigs :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatModelConfig + for rows.Next() { + var i ChatModelConfig + if err := rows.Scan( + &i.ID, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + &i.AIProviderID, + &i.OrganizationID, + &i.GroupACL, + &i.UserACL, + ); 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 templateQuerier interface { GetAuthorizedTemplates(ctx context.Context, arg GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]Template, error) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]TemplateGroup, error) diff --git a/coderd/database/models.go b/coderd/database/models.go index 93d19300a74..2f379be3091 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -529,6 +529,12 @@ const ( ApiKeyScopeMcpServerConfigUpdate APIKeyScope = "mcp_server_config:update" ApiKeyScopeMcpServerConfigDelete APIKeyScope = "mcp_server_config:delete" ApiKeyScopeMcpServerConfigShare APIKeyScope = "mcp_server_config:share" + ApiKeyScopeChatModelConfig APIKeyScope = "chat_model_config:*" + ApiKeyScopeChatModelConfigCreate APIKeyScope = "chat_model_config:create" + ApiKeyScopeChatModelConfigRead APIKeyScope = "chat_model_config:read" + ApiKeyScopeChatModelConfigUpdate APIKeyScope = "chat_model_config:update" + ApiKeyScopeChatModelConfigDelete APIKeyScope = "chat_model_config:delete" + ApiKeyScopeChatModelConfigShare APIKeyScope = "chat_model_config:share" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -809,7 +815,13 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeMcpServerConfigRead, ApiKeyScopeMcpServerConfigUpdate, ApiKeyScopeMcpServerConfigDelete, - ApiKeyScopeMcpServerConfigShare: + ApiKeyScopeMcpServerConfigShare, + ApiKeyScopeChatModelConfig, + ApiKeyScopeChatModelConfigCreate, + ApiKeyScopeChatModelConfigRead, + ApiKeyScopeChatModelConfigUpdate, + ApiKeyScopeChatModelConfigDelete, + ApiKeyScopeChatModelConfigShare: return true } return false @@ -1059,6 +1071,12 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeMcpServerConfigUpdate, ApiKeyScopeMcpServerConfigDelete, ApiKeyScopeMcpServerConfigShare, + ApiKeyScopeChatModelConfig, + ApiKeyScopeChatModelConfigCreate, + ApiKeyScopeChatModelConfigRead, + ApiKeyScopeChatModelConfigUpdate, + ApiKeyScopeChatModelConfigDelete, + ApiKeyScopeChatModelConfigShare, } } @@ -3613,6 +3631,7 @@ const ( ResourceTypeOauth2ProviderSettings ResourceType = "oauth2_provider_settings" ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" ResourceTypeMCPServerConfig ResourceType = "mcp_server_config" + ResourceTypeChatModelConfig ResourceType = "chat_model_config" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3689,7 +3708,8 @@ func (e ResourceType) Valid() bool { ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, ResourceTypeChatInstructionSettings, - ResourceTypeMCPServerConfig: + ResourceTypeMCPServerConfig, + ResourceTypeChatModelConfig: return true } return false @@ -3735,6 +3755,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeOauth2ProviderSettings, ResourceTypeChatInstructionSettings, ResourceTypeMCPServerConfig, + ResourceTypeChatModelConfig, } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a9c041da0fa..00dd4cc0545 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1483,6 +1483,7 @@ type sqlcQuerier interface { UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) + UpdateChatModelConfigACLByID(ctx context.Context, arg UpdateChatModelConfigACLByIDParams) (ChatModelConfig, error) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) // Stores the client-visible retry payload. retry_state_version is diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 81526b9baf3..6bc0b0eeabd 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12366,6 +12366,83 @@ func TestDeleteChatModelConfigByID(t *testing.T) { require.ErrorIs(t, err, sql.ErrNoRows) } +func TestUpdateChatModelConfigACLByID(t *testing.T) { + t.Parallel() + + store, _ := dbtestutil.NewDB(t) + + tests := []struct { + name string + groupACL database.ChatACL + userACL database.ChatACL + }{ + { + name: "Replace", + groupACL: database.ChatACL{ + uuid.NewString(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + userACL: database.ChatACL{ + uuid.NewString(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + }, + { + name: "Clear", + groupACL: database.ChatACL{}, + userACL: database.ChatACL{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + organization := dbgen.Organization(t, store, database.Organization{}) + creator := dbgen.User(t, store, database.User{}) + updater := dbgen.User(t, store, database.User{}) + config := dbgen.ChatModelConfig(t, store, database.ChatModelConfig{ + Model: "acl-model-" + uuid.NewString(), + DisplayName: "ACL model " + uuid.NewString(), + CreatedBy: uuid.NullUUID{UUID: creator.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: creator.ID, Valid: true}, + IsDefault: true, + ContextLimit: 4242, + CompressionThreshold: 37, + Options: json.RawMessage(`{"temperature":0.25}`), + OrganizationID: organization.ID, + GroupACL: database.ChatACL{ + organization.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + UserACL: database.ChatACL{ + creator.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + }) + + before, err := store.GetChatModelConfigByID(ctx, config.ID) + require.NoError(t, err) + + updated, err := store.UpdateChatModelConfigACLByID(ctx, database.UpdateChatModelConfigACLByIDParams{ + ID: config.ID, + GroupACL: tt.groupACL, + UserACL: tt.userACL, + UpdatedBy: uuid.NullUUID{UUID: updater.ID, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, tt.groupACL, updated.GroupACL) + require.Equal(t, tt.userACL, updated.UserACL) + require.Equal(t, uuid.NullUUID{UUID: updater.ID, Valid: true}, updated.UpdatedBy) + require.True(t, updated.UpdatedAt.After(before.UpdatedAt)) + + expected := before + expected.GroupACL = tt.groupACL + expected.UserACL = tt.userACL + expected.UpdatedBy = uuid.NullUUID{UUID: updater.ID, Valid: true} + expected.UpdatedAt = updated.UpdatedAt + require.Equal(t, expected, updated) + }) + } +} + func TestGetEnabledChatModelConfigsUsesAIProviders(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 65e8b7e4746..c3759bea71e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6200,6 +6200,8 @@ LEFT JOIN ai_providers ap ON ap.id = cmc.ai_provider_id WHERE cmc.deleted = FALSE + -- Authorize Filter clause will be injected below in GetAuthorizedChatModelConfigs + -- @authorize_filter ORDER BY ap.type::text ASC, cmc.model ASC, @@ -6644,6 +6646,59 @@ func (q *sqlQuerier) UpdateChatModelConfig(ctx context.Context, arg UpdateChatMo return i, err } +const updateChatModelConfigACLByID = `-- name: UpdateChatModelConfigACLByID :one +UPDATE + chat_model_configs +SET + group_acl = $1, + user_acl = $2, + updated_by = $3::uuid, + updated_at = NOW() +WHERE + id = $4::uuid + AND deleted = FALSE +RETURNING + id, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options, ai_provider_id, organization_id, group_acl, user_acl +` + +type UpdateChatModelConfigACLByIDParams struct { + GroupACL ChatACL `db:"group_acl" json:"group_acl"` + UserACL ChatACL `db:"user_acl" json:"user_acl"` + UpdatedBy uuid.NullUUID `db:"updated_by" json:"updated_by"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateChatModelConfigACLByID(ctx context.Context, arg UpdateChatModelConfigACLByIDParams) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, updateChatModelConfigACLByID, + arg.GroupACL, + arg.UserACL, + arg.UpdatedBy, + arg.ID, + ) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + &i.AIProviderID, + &i.OrganizationID, + &i.GroupACL, + &i.UserACL, + ) + return i, err +} + const acquireStaleChatDiffStatuses = `-- name: AcquireStaleChatDiffStatuses :many WITH acquired AS ( UPDATE diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql index 7c64fbcef83..671440a47be 100644 --- a/coderd/database/queries/chatmodelconfigs.sql +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -26,6 +26,8 @@ LEFT JOIN ai_providers ap ON ap.id = cmc.ai_provider_id WHERE cmc.deleted = FALSE + -- Authorize Filter clause will be injected below in GetAuthorizedChatModelConfigs + -- @authorize_filter ORDER BY ap.type::text ASC, cmc.model ASC, @@ -140,6 +142,20 @@ WHERE RETURNING *; +-- name: UpdateChatModelConfigACLByID :one +UPDATE + chat_model_configs +SET + group_acl = @group_acl, + user_acl = @user_acl, + updated_by = sqlc.narg('updated_by')::uuid, + updated_at = NOW() +WHERE + id = @id::uuid + AND deleted = FALSE +RETURNING + *; + -- name: UnsetDefaultChatModelConfigs :exec UPDATE chat_model_configs diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 45e5fcc3546..f572ba7439c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6928,6 +6928,12 @@ func (*API) deleteUserChatProviderKey(rw http.ResponseWriter, r *http.Request) { func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + apiKey := httpmw.APIKey(r) + + if !chatModelConfigReadScope(apiKey.Scopes) { + httpapi.Forbidden(rw) + return + } defaultOrg, err := defaultOrganizationForChatModelConfigs(ctx, api.Database) if err != nil { @@ -6938,32 +6944,7 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { return } - // Admin users can see disabled configs. Non-admin users see only enabled - // configs. These deployment-wide routes manage the default organization. - isAdmin := api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) - - var configs []database.ChatModelConfig - if isAdmin { - allConfigs, listErr := api.Database.GetChatModelConfigs(ctx) - err = listErr - configs = make([]database.ChatModelConfig, 0, len(allConfigs)) - for _, config := range allConfigs { - if config.OrganizationID == defaultOrg.ID { - configs = append(configs, config) - } - } - } else { - //nolint:gocritic // All authenticated users need the shared enabled models. - rows, rowsErr := api.Database.GetEnabledChatModelConfigsByOrganization( - dbauthz.AsChatd(ctx), - defaultOrg.ID, - ) - err = rowsErr - configs = make([]database.ChatModelConfig, 0, len(rows)) - for _, row := range rows { - configs = append(configs, row.ChatModelConfig) - } - } + allConfigs, err := api.Database.GetChatModelConfigs(ctx) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to list chat model configs.", @@ -6972,14 +6953,21 @@ func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) { return } - resp := make([]codersdk.ChatModelConfig, 0, len(configs)) - for _, config := range configs { - resp = append(resp, convertChatModelConfig(config)) + resp := make([]codersdk.ChatModelConfig, 0, len(allConfigs)) + for _, config := range allConfigs { + if config.OrganizationID == defaultOrg.ID { + resp = append(resp, convertChatModelConfig(config)) + } } httpapi.Write(ctx, rw, http.StatusOK, resp) } +func chatModelConfigReadScope(scopes database.APIKeyScopes) bool { + return scopes.Has(database.ApiKeyScopeCoderAll) || + scopes.Has(database.ApiKeyScopeChatModelConfigRead) +} + type chatModelConfigProviderModelError struct { Response codersdk.Response } @@ -7029,6 +7017,8 @@ func (api *API) inChatModelConfigWriteTx( func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + // Interim write gate: see the dbauthz InsertChatModelConfig check. + // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover) if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return @@ -7228,6 +7218,8 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) + // Interim write gate: see createChatModelConfig. + // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover) if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return @@ -7451,6 +7443,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + // Interim write gate: see createChatModelConfig. + // TODO(mafredri): remove after CODAGT-709 M3 (org-scoping cutover) if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { httpapi.Forbidden(rw) return @@ -7527,9 +7521,9 @@ func ensureDefaultChatModelConfig( return xerrors.Errorf("get default model config: %w", err) } - modelConfigs, err := tx.GetChatModelConfigs(ctx) + modelConfigs, err := tx.GetDefaultChatModelConfigCandidates(ctx) if err != nil { - return xerrors.Errorf("list chat model configs: %w", err) + return xerrors.Errorf("list default chat model config candidates: %w", err) } orgModelConfigs := make([]database.ChatModelConfig, 0, len(modelConfigs)) for _, config := range modelConfigs { diff --git a/coderd/exp_chats_model_config_list_test.go b/coderd/exp_chats_model_config_list_test.go new file mode 100644 index 00000000000..c1478f02243 --- /dev/null +++ b/coderd/exp_chats_model_config_list_test.go @@ -0,0 +1,256 @@ +package coderd_test + +import ( + "context" + "net/http" + "slices" + "testing" + + "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/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// TestChatModelConfigListReadContracts pins the visible config set for each +// role. The legacy endpoint reads only the default organization's configs. +func TestChatModelConfigListReadContracts(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Database = rawDB + opts.Pubsub = pubsub + }) + _ = coderdtest.CreateFirstUser(t, client.Client) + + defaultOrg, err := rawDB.GetDefaultOrganization(ctx) + require.NoError(t, err) + otherOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false}) + seedEveryoneGroup(t, rawDB, otherOrg.ID) + + ownEnabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{ + OrganizationID: defaultOrg.ID, + GroupACL: database.ChatACL{ + defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + }) + ownDisabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{ + OrganizationID: defaultOrg.ID, + GroupACL: database.ChatACL{ + defaultOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + }, func(params *database.InsertChatModelConfigParams) { + params.Enabled = false + }) + denied := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{ + OrganizationID: defaultOrg.ID, + GroupACL: database.ChatACL{}, + UserACL: database.ChatACL{}, + }) + otherEnabled := dbgen.ChatModelConfig(t, rawDB, database.ChatModelConfig{ + OrganizationID: otherOrg.ID, + GroupACL: database.ChatACL{ + otherOrg.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + }) + require.True(t, ownEnabled.Enabled) + require.False(t, ownDisabled.Enabled) + require.True(t, denied.Enabled) + require.True(t, otherEnabled.Enabled) + + for _, testCase := range []struct { + name string + scope codersdk.APIKeyScope + wantStatus int + }{ + {name: "ModelRead", scope: codersdk.APIKeyScopeChatModelConfigRead}, + {name: "WorkspaceRead", scope: codersdk.APIKeyScopeWorkspaceRead, wantStatus: http.StatusForbidden}, + } { + t.Run("TokenScope/"+testCase.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{testCase.scope}, + }) + require.NoError(t, err) + scopedClient := codersdk.New( + client.URL, + codersdk.WithSessionToken(token.Key), + codersdk.WithHTTPClient(coderdtest.NewIsolatedHTTPClient(client.URL)), + ) + t.Cleanup(scopedClient.HTTPClient.CloseIdleConnections) + + configs, err := codersdk.NewExperimentalClient(scopedClient).ListChatModelConfigs(ctx) + if testCase.wantStatus != 0 { + requireSDKError(t, err, testCase.wantStatus) + return + } + require.NoError(t, err) + require.NotEmpty(t, configs) + }) + } + + testCases := []struct { + name string + client func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient + visible []uuid.UUID + hidden []uuid.UUID + }{ + { + name: "OwnerSeesDefaultOrgConfigs", + client: func(*testing.T, context.Context) *codersdk.ExperimentalClient { + return client + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, + hidden: []uuid.UUID{otherEnabled.ID}, + }, + { + name: "SiteAuditorSeesDefaultOrgConfigs", + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.RoleAuditor()) + return codersdk.NewExperimentalClient(rawClient) + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, + hidden: []uuid.UUID{otherEnabled.ID}, + }, + { + name: "CustomSiteReadRoleSeesDefaultOrgConfigs", + client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient { + return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{ + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionRead, + }) + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, + hidden: []uuid.UUID{otherEnabled.ID}, + }, + { + name: "AgentsAccessUsesRowACL", + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser( + t, + client.Client, + defaultOrg.ID, + rbac.ScopedRoleAgentsAccess(defaultOrg.ID), + ) + return codersdk.NewExperimentalClient(rawClient) + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID}, + hidden: []uuid.UUID{denied.ID, otherEnabled.ID}, + }, + { + name: "DeploymentConfigReadOnlyUsesRowACL", + client: func(t *testing.T, ctx context.Context) *codersdk.ExperimentalClient { + return newSiteCustomRoleClient(ctx, t, client, rawDB, defaultOrg.ID, database.CustomRolePermission{ + ResourceType: rbac.ResourceDeploymentConfig.Type, + Action: policy.ActionRead, + }) + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID}, + hidden: []uuid.UUID{denied.ID, otherEnabled.ID}, + }, + { + name: "DefaultOrgAdminKeepsEnabledList", + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAdmin(defaultOrg.ID)) + return codersdk.NewExperimentalClient(rawClient) + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, + hidden: []uuid.UUID{otherEnabled.ID}, + }, + { + name: "DefaultOrgAuditorKeepsEnabledList", + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, defaultOrg.ID, rbac.ScopedRoleOrgAuditor(defaultOrg.ID)) + return codersdk.NewExperimentalClient(rawClient) + }, + visible: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID}, + hidden: []uuid.UUID{otherEnabled.ID}, + }, + { + name: "NonDefaultOrgAdminSeesNoDefaultOrgConfigs", + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAdmin(otherOrg.ID)) + return codersdk.NewExperimentalClient(rawClient) + }, + hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID}, + }, + { + name: "NonDefaultOrgAuditorSeesNoDefaultOrgConfigs", + client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { + rawClient, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleOrgAuditor(otherOrg.ID)) + return codersdk.NewExperimentalClient(rawClient) + }, + hidden: []uuid.UUID{ownEnabled.ID, ownDisabled.ID, denied.ID, otherEnabled.ID}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + configs, err := testCase.client(t, ctx).ListChatModelConfigs(ctx) + require.NoError(t, err) + for _, id := range testCase.visible { + require.True(t, containsChatModelConfig(configs, id), "must see config %s", id) + } + for _, id := range testCase.hidden { + require.False(t, containsChatModelConfig(configs, id), "must not see config %s", id) + } + }) + } +} + +func containsChatModelConfig(configs []codersdk.ChatModelConfig, id uuid.UUID) bool { + return slices.ContainsFunc(configs, func(config codersdk.ChatModelConfig) bool { + return config.ID == id + }) +} + +func seedEveryoneGroup(t testing.TB, db database.Store, organizationID uuid.UUID) { + t.Helper() + dbgen.Group(t, db, database.Group{ + ID: organizationID, + Name: database.EveryoneGroup, + OrganizationID: organizationID, + }) +} + +// newSiteCustomRoleClient seeds a null-org role through the raw store because +// public custom-role APIs create organization roles and reject site permissions. +func newSiteCustomRoleClient( + ctx context.Context, + t testing.TB, + ownerClient *codersdk.ExperimentalClient, + db database.Store, + organizationID uuid.UUID, + permissions ...database.CustomRolePermission, +) *codersdk.ExperimentalClient { + t.Helper() + + role, err := db.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "Site Custom Test Role", + OrganizationID: uuid.NullUUID{}, + SitePermissions: permissions, + }) + require.NoError(t, err) + + rawClient, user := coderdtest.CreateAnotherUser(t, ownerClient.Client, organizationID) + _, err = ownerClient.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ + Roles: []string{role.Name}, + }) + require.NoError(t, err) + return codersdk.NewExperimentalClient(rawClient) +} diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 6614afb9ba1..abca4d0f6ef 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -4590,7 +4590,7 @@ func TestListChatModelConfigs(t *testing.T) { require.True(t, found) }) - t.Run("NonAdminExcludesDisabledModelConfigs", func(t *testing.T) { + t.Run("NonAdminIncludesAllowedDisabledModelConfigs", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -4613,14 +4613,16 @@ func TestListChatModelConfigs(t *testing.T) { configs, err := memberClient.ListChatModelConfigs(ctx) require.NoError(t, err) - require.Len(t, configs, 1) - require.Equal(t, enabledConfig.ID, configs[0].ID) - require.True(t, configs[0].Enabled) + require.Len(t, configs, 2) + require.True(t, slices.ContainsFunc(configs, func(config codersdk.ChatModelConfig) bool { + return config.ID == enabledConfig.ID && config.Enabled + })) + require.True(t, slices.ContainsFunc(configs, func(config codersdk.ChatModelConfig) bool { + return config.Model == "gpt-4o-disabled" && !config.Enabled + })) }) - // An enabled config under a disabled provider must stay visible to - // admins (management view) while being hidden from non-admins (usage - // view). + // Provider status does not change management list visibility. t.Run("ProviderDisabled", func(t *testing.T) { t.Parallel() @@ -4647,8 +4649,12 @@ func TestListChatModelConfigs(t *testing.T) { memberConfigs, err := memberClient.ListChatModelConfigs(ctx) require.NoError(t, err) - require.Len(t, memberConfigs, 1) - require.Equal(t, enabledConfig.ID, memberConfigs[0].ID) + memberIDs := make([]uuid.UUID, 0, len(memberConfigs)) + for _, config := range memberConfigs { + memberIDs = append(memberIDs, config.ID) + } + require.Contains(t, memberIDs, enabledConfig.ID) + require.Contains(t, memberIDs, providerDisabledConfig.ID) }) t.Run("ManagementListExcludesOtherOrganizations", func(t *testing.T) { @@ -4682,7 +4688,7 @@ func TestListChatModelConfigs(t *testing.T) { memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - // Non-admin users should see only enabled model configs. + // The management list returns all rows allowed by the row ACL. configs, err := memberClient.ListChatModelConfigs(ctx) require.NoError(t, err) require.NotEmpty(t, configs) @@ -4814,9 +4820,6 @@ func TestCreateChatModelConfig(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - // dbauthz rejects site-permission roles on the authorized - // querier; seed the role through the raw store, as persisted - // site custom roles exist in production deployments. rawDB, pubsub := dbtestutil.NewDB(t) client := newChatClient(t, func(opts *coderdtest.Options) { opts.Database = rawDB @@ -4825,50 +4828,25 @@ func TestCreateChatModelConfig(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) nonDefaultOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false}) - // A custom site role holding only deployment-config permissions - // passes the route gate but cannot read the default organization - // object; the default-org resolution is an internal step and must - // not require caller-held organization read. The owning org is a - // label on the role; the site permissions apply deployment-wide. - role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ - Name: testutil.GetRandomName(t), - DisplayName: "Deployment Config Admin", - OrganizationID: uuid.NullUUID{UUID: nonDefaultOrg.ID, Valid: true}, - SitePermissions: database.CustomRolePermissions{ - { - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionRead, - }, - { - ResourceType: rbac.ResourceDeploymentConfig.Type, - Action: policy.ActionUpdate, - }, + // A restricted system context supplies organization read permission for + // the internal lookup, so the user's role does not require it. + adminClient := newSiteCustomRoleClient( + ctx, + t, + client, + rawDB, + nonDefaultOrg.ID, + database.CustomRolePermission{ + ResourceType: rbac.ResourceDeploymentConfig.Type, + Action: policy.ActionRead, }, - }) - require.NoError(t, err) + database.CustomRolePermission{ + ResourceType: rbac.ResourceDeploymentConfig.Type, + Action: policy.ActionUpdate, + }, + ) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") - // An existing deployment default keeps the create off the - // self-promotion path, which is a separate system-scoped - // authorization requirement. - _ = createChatModelConfig(t, client) - - // A member of a non-default org cannot read the default - // organization object; the implicit organization-member role - // grants organization read only within its own org. - adminClientRaw, adminUser := coderdtest.CreateAnotherUser( - t, - client.Client, - nonDefaultOrg.ID, - ) - _, err = client.UpdateOrganizationMemberRoles( - ctx, - nonDefaultOrg.ID, - adminUser.ID.String(), - codersdk.UpdateRoles{Roles: []string{role.Name}}, - ) - require.NoError(t, err) - adminClient := codersdk.NewExperimentalClient(adminClientRaw) contextLimit := int64(4096) modelConfig, err := adminClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ @@ -4883,7 +4861,7 @@ func TestCreateChatModelConfig(t *testing.T) { row, err := rawDB.GetChatModelConfigByID(ctx, modelConfig.ID) require.NoError(t, err) require.Equal(t, defaultOrg.ID, row.OrganizationID) - require.False(t, row.IsDefault) + require.True(t, row.IsDefault) require.Equal( t, database.ChatACL{ @@ -4894,6 +4872,102 @@ func TestCreateChatModelConfig(t *testing.T) { require.Equal(t, database.ChatACL{}, row.UserACL) }) + // Deployment-config administrators can manage configs and defaults. + t.Run("DeploymentConfigOnlyRoleWritesThroughWindow", func(t *testing.T) { + t.Parallel() + + setupCtx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Database = rawDB + opts.Pubsub = pubsub + }) + _ = coderdtest.CreateFirstUser(t, client.Client) + nonDefaultOrg := dbgen.Organization(t, rawDB, database.Organization{IsDefault: false}) + + writerClient := newSiteCustomRoleClient( + setupCtx, + t, + client, + rawDB, + nonDefaultOrg.ID, + database.CustomRolePermission{ + ResourceType: rbac.ResourceDeploymentConfig.Type, + Action: policy.ActionRead, + }, + database.CustomRolePermission{ + ResourceType: rbac.ResourceDeploymentConfig.Type, + Action: policy.ActionUpdate, + }, + ) + + aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + // The first config self-elects as the deployment default. The existing + // default keeps the writer's own create off the self-promotion path. + defaultConfig := createChatModelConfig(t, client) + + contextLimit := int64(4096) + modelConfig, err := writerClient.CreateChatModelConfig(setupCtx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + + updated, err := writerClient.UpdateChatModelConfig(setupCtx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + DisplayName: "Window Write", + }) + require.NoError(t, err) + require.Equal(t, "Window Write", updated.DisplayName) + + require.NoError(t, writerClient.DeleteChatModelConfig(setupCtx, modelConfig.ID)) + + // TODO(mafredri): remove these default-transition checks after + // CODAGT-709 M3 (org-scoping cutover). + ctx := testutil.Context(t, testutil.WaitLong) + notDefault := false + updated, err = writerClient.UpdateChatModelConfig(ctx, defaultConfig.ID, codersdk.UpdateChatModelConfigRequest{ + IsDefault: ¬Default, + }) + require.NoError(t, err) + require.True(t, updated.IsDefault, "the sole config must remain default") + row, err := rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) + require.NoError(t, err) + require.True(t, row.IsDefault, "the sole config must remain default") + + replacement, err := writerClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini-replacement", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + require.NoError(t, writerClient.DeleteChatModelConfig(ctx, defaultConfig.ID)) + _, err = rawDB.GetChatModelConfigByID(ctx, defaultConfig.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + row, err = rawDB.GetChatModelConfigByID(ctx, replacement.ID) + require.NoError(t, err) + require.True(t, row.IsDefault, "replacement must be promoted") + + candidate, err := writerClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini-promote", + ContextLimit: &contextLimit, + }) + require.NoError(t, err) + isDefault := true + updated, err = writerClient.UpdateChatModelConfig(ctx, candidate.ID, codersdk.UpdateChatModelConfigRequest{ + IsDefault: &isDefault, + }) + require.NoError(t, err) + require.True(t, updated.IsDefault) + row, err = rawDB.GetChatModelConfigByID(ctx, candidate.ID) + require.NoError(t, err) + require.True(t, row.IsDefault, "candidate must be promoted") + row, err = rawDB.GetChatModelConfigByID(ctx, replacement.ID) + require.NoError(t, err) + require.False(t, row.IsDefault, "existing default must be demoted") + }) + t.Run("ReasoningEffortStored", func(t *testing.T) { t.Parallel() @@ -5393,7 +5467,7 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Contains(t, sdkErr.Detail, "Change the AI provider type to openrouter or openai-compat.") }) - t.Run("DisablePreservesRecordAndHidesItFromNonAdmins", func(t *testing.T) { + t.Run("DisablePreservesRecordAndStateForNonAdmins", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -5425,12 +5499,18 @@ func TestUpdateChatModelConfig(t *testing.T) { memberConfigs, err := memberClient.ListChatModelConfigs(ctx) require.NoError(t, err) + + foundForMember := false for _, config := range memberConfigs { - require.NotEqual(t, modelConfig.ID, config.ID) + if config.ID == modelConfig.ID { + foundForMember = true + require.False(t, config.Enabled) + } } + require.True(t, foundForMember) }) - t.Run("ReEnableRestoresVisibilityForNonAdmins", func(t *testing.T) { + t.Run("ReEnableUpdatesStateForNonAdmins", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -5460,9 +5540,10 @@ func TestUpdateChatModelConfig(t *testing.T) { for _, config := range memberConfigs { if config.ID == modelConfig.ID { foundForMember = true + require.False(t, config.Enabled) } } - require.False(t, foundForMember) + require.True(t, foundForMember) enabled = true updated, err := adminClient.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 3fb873a64c8..4ea4eadd55d 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -745,6 +745,14 @@ func ConfigWorkspaces() regosql.ConvertConfig { } } +// ConfigChatModelConfigs uses a resource converter so SQL filters qualify +// chat model config columns consistently with GetChatModelConfigs. +func ConfigChatModelConfigs() regosql.ConvertConfig { + return regosql.ConvertConfig{ + VariableConverter: regosql.ChatModelConfigConverter(), + } +} + func Compile(cfg regosql.ConvertConfig, pa *PartialAuthorizer) (AuthorizeFilter, error) { root, err := regosql.ConvertRegoAst(cfg, pa.partialQueries) if err != nil { diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index f7cfafc60a4..c1e43125002 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -128,6 +128,17 @@ var ( Type: "chat", } + // ResourceChatModelConfig + // Valid Actions + // - "ActionCreate" :: create a new chat model config + // - "ActionDelete" :: delete a chat model config + // - "ActionRead" :: read chat model configs + // - "ActionShare" :: share a chat model config with other users or groups + // - "ActionUpdate" :: update a chat model config + ResourceChatModelConfig = Object{ + Type: "chat_model_config", + } + // ResourceConnectionLog // Valid Actions // - "ActionRead" :: read connection logs @@ -522,6 +533,7 @@ func AllResources() []Objecter { ResourceBoundaryLog, ResourceBoundaryUsage, ResourceChat, + ResourceChatModelConfig, ResourceConnectionLog, ResourceCryptoKey, ResourceDebugInfo, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 7c75ec4e8d3..067c42ee0f4 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -93,6 +93,14 @@ var mcpServerConfigActions = map[Action]ActionDefinition{ ActionDelete: "delete MCP server config", } +var chatModelConfigActions = map[Action]ActionDefinition{ + ActionCreate: "create a new chat model config", + ActionRead: "read chat model configs", + ActionUpdate: "update a chat model config", + ActionDelete: "delete a chat model config", + ActionShare: "share a chat model config with other users or groups", +} + // RBACPermissions is indexed by the type var RBACPermissions = map[string]PermissionDefinition{ // Wildcard is every object, and the action "*" provides all actions. @@ -122,6 +130,9 @@ var RBACPermissions = map[string]PermissionDefinition{ "chat": { Actions: chatActions, }, + "chat_model_config": { + Actions: chatModelConfigActions, + }, // Dormant workspaces have the same perms as workspaces. "workspace_dormant": { Actions: workspaceActions, diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index ddba44b0130..be5f6a944ad 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -227,6 +227,52 @@ func TestRegoQueries(t *testing.T) { " OR (chats_expanded.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? '*'))", VariableConverter: regosql.ChatConverter(), }, + { + Name: "UserChatModelConfigACLAllow", + Queries: []string{ + `"read" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`, + `"*" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`, + }, + ExpectedSQL: "((cmc.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? 'read')" + + " OR (cmc.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? '*'))", + VariableConverter: regosql.ChatModelConfigConverter(), + }, + { + Name: "GroupChatModelConfigACLAllow", + Queries: []string{ + `"read" in input.object.acl_group_list["96c55a0e-73b4-44fc-abac-70d53c35c04c"]`, + `"*" in input.object.acl_group_list["96c55a0e-73b4-44fc-abac-70d53c35c04c"]`, + }, + ExpectedSQL: "((cmc.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? 'read')" + + " OR (cmc.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? '*'))", + VariableConverter: regosql.ChatModelConfigConverter(), + }, + { + Name: "ChatModelConfigOrgScopedMatches", + Queries: []string{ + `input.object.org_owner = "org-id"`, + }, + ExpectedSQL: p("cmc.organization_id :: text = 'org-id'"), + VariableConverter: regosql.ChatModelConfigConverter(), + }, + { + Name: "ChatModelConfigNoOwner", + Queries: []string{ + `input.object.owner != ""`, + }, + ExpectedSQL: p("false"), + VariableConverter: regosql.ChatModelConfigConverter(), + }, + { + Name: "ChatModelConfigAllowList", + Queries: []string{ + `input.object.id != ""`, + `input.object.id in ["9046b041-58ed-47a3-9c3a-de302577875a"]`, + }, + ExpectedSQL: p(`(cmc.id :: text != '') OR ` + + `(cmc.id :: text = ANY(ARRAY ['9046b041-58ed-47a3-9c3a-de302577875a']))`), + VariableConverter: regosql.ChatModelConfigConverter(), + }, { Name: "ChatAllowList", Queries: []string{ diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index f241e233e02..e0a8ad2378b 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -95,6 +95,23 @@ func chatBaseConverter() *sqltypes.VariableConverter { ) } +// ChatModelConfigConverter qualifies columns against the cmc alias used by +// GetChatModelConfigs. Chat model configs have no user owner, +// only an organization owner. +func ChatModelConfigConverter() *sqltypes.VariableConverter { + matcher := sqltypes.NewVariableConverter().RegisterMatcher( + sqltypes.StringVarMatcher("cmc.id :: text", []string{"input", "object", "id"}), + sqltypes.StringVarMatcher("cmc.organization_id :: text", []string{"input", "object", "org_owner"}), + sqltypes.AlwaysFalse(userOwnerMatcher()), + ) + matcher.RegisterMatcher( + ACLMappingMatcher(matcher, "cmc.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"), + ACLMappingMatcher(matcher, "cmc.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"), + ) + + return matcher +} + func AuditLogConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index fdb462b7eb6..3aceb983238 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -476,6 +476,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {policy.ActionRead}, ResourceDeploymentConfig.Type: {policy.ActionRead}, + ResourceChatModelConfig.Type: {policy.ActionRead}, // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, // Allow auditors to read boundary logs. @@ -611,6 +612,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceGroupMember.Type: {policy.ActionRead}, ResourceOrganization.Type: {policy.ActionRead}, ResourceOrganizationMember.Type: {policy.ActionRead}, + ResourceChatModelConfig.Type: {policy.ActionRead}, }), Member: []Permission{}, }, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index c04a16dfe33..060ff175b05 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1461,6 +1461,35 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, memberMe, orgMemberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, + { + Name: "ChatModelConfigRead", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceChatModelConfig.WithID(uuid.New()).InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin, auditor, orgAuditor}, + false: { + memberMe, orgMemberMe, agentsAccessUser, orgWorkspaceAccessUser, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + otherOrgAdmin, otherOrgAuditor, + }, + }, + }, + { + Name: "ChatModelConfigCreateUpdateDeleteShare", + Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare}, + Resource: rbac.ResourceChatModelConfig.WithID(uuid.New()).InOrg(orgID), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin}, + false: { + memberMe, orgMemberMe, agentsAccessUser, orgWorkspaceAccessUser, + auditor, orgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + otherOrgAdmin, otherOrgAuditor, + }, + }, + }, } // Build coverage set from test case definitions statically, // so we don't need shared mutable state during execution. diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index dd7f0871cca..8d6faf39b05 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -35,6 +35,10 @@ var externalLowLevel = map[ScopeName]struct{}{ "api_key:delete": {}, "api_key:*": {}, + // Chat model configs + "chat_model_config:read": {}, + "chat_model_config:share": {}, + // Files "file:read": {}, "file:create": {}, diff --git a/coderd/rbac/scopes_catalog_internal_test.go b/coderd/rbac/scopes_catalog_internal_test.go index fccb240b990..3cac0062e7f 100644 --- a/coderd/rbac/scopes_catalog_internal_test.go +++ b/coderd/rbac/scopes_catalog_internal_test.go @@ -63,6 +63,7 @@ func TestIsExternalScope(t *testing.T) { require.True(t, IsExternalScope("workspace:*")) require.True(t, IsExternalScope("coder:workspaces.create")) require.True(t, IsExternalScope("user:read")) + require.True(t, IsExternalScope("chat_model_config:share")) require.False(t, IsExternalScope("debug_info:read")) // internal-only require.False(t, IsExternalScope("unknown:read")) } diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 33ba267e931..aa8f95fbfc4 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -48,6 +48,11 @@ const ( ScopeChatRead ScopeName = "chat:read" ScopeChatShare ScopeName = "chat:share" ScopeChatUpdate ScopeName = "chat:update" + ScopeChatModelConfigCreate ScopeName = "chat_model_config:create" + ScopeChatModelConfigDelete ScopeName = "chat_model_config:delete" + ScopeChatModelConfigRead ScopeName = "chat_model_config:read" + ScopeChatModelConfigShare ScopeName = "chat_model_config:share" + ScopeChatModelConfigUpdate ScopeName = "chat_model_config:update" ScopeConnectionLogRead ScopeName = "connection_log:read" ScopeConnectionLogUpdate ScopeName = "connection_log:update" ScopeCryptoKeyCreate ScopeName = "crypto_key:create" @@ -241,6 +246,11 @@ func (e ScopeName) Valid() bool { ScopeChatRead, ScopeChatShare, ScopeChatUpdate, + ScopeChatModelConfigCreate, + ScopeChatModelConfigDelete, + ScopeChatModelConfigRead, + ScopeChatModelConfigShare, + ScopeChatModelConfigUpdate, ScopeConnectionLogRead, ScopeConnectionLogUpdate, ScopeCryptoKeyCreate, @@ -435,6 +445,11 @@ func AllScopeNameValues() []ScopeName { ScopeChatRead, ScopeChatShare, ScopeChatUpdate, + ScopeChatModelConfigCreate, + ScopeChatModelConfigDelete, + ScopeChatModelConfigRead, + ScopeChatModelConfigShare, + ScopeChatModelConfigUpdate, ScopeConnectionLogRead, ScopeConnectionLogUpdate, ScopeCryptoKeyCreate, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 4d540f06d8a..ac96eb393da 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2460,7 +2460,7 @@ func (p *Server) RegenerateChatTitle( ctx context.Context, chat database.Chat, ) (database.Chat, error) { - // Reuse chatd's scoped auth context for deployment-config lookups while + // Reuse chatd's scoped auth context for chat model config reads while // keeping chat ownership authorization at the HTTP layer. //nolint:gocritic // Non-admin users need chatd-scoped config reads here. chatdCtx := dbauthz.AsChatd(ctx) diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 3284b0b04be..13cbd38416a 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -59,6 +59,12 @@ const ( APIKeyScopeChatRead APIKeyScope = "chat:read" APIKeyScopeChatShare APIKeyScope = "chat:share" APIKeyScopeChatUpdate APIKeyScope = "chat:update" + APIKeyScopeChatModelConfigAll APIKeyScope = "chat_model_config:*" + APIKeyScopeChatModelConfigCreate APIKeyScope = "chat_model_config:create" + APIKeyScopeChatModelConfigDelete APIKeyScope = "chat_model_config:delete" + APIKeyScopeChatModelConfigRead APIKeyScope = "chat_model_config:read" + APIKeyScopeChatModelConfigShare APIKeyScope = "chat_model_config:share" + APIKeyScopeChatModelConfigUpdate APIKeyScope = "chat_model_config:update" APIKeyScopeCoderAll APIKeyScope = "coder:all" APIKeyScopeCoderApikeysManageSelf APIKeyScope = "coder:apikeys.manage_self" APIKeyScopeCoderApplicationConnect APIKeyScope = "coder:application_connect" @@ -257,6 +263,8 @@ var PublicAPIKeyScopes = []APIKeyScope{ APIKeyScopeApiKeyDelete, APIKeyScopeApiKeyRead, APIKeyScopeApiKeyUpdate, + APIKeyScopeChatModelConfigRead, + APIKeyScopeChatModelConfigShare, APIKeyScopeCoderAll, APIKeyScopeCoderApikeysManageSelf, APIKeyScopeCoderApplicationConnect, diff --git a/codersdk/audit.go b/codersdk/audit.go index 1e8131dd373..d95dc681ab3 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -54,6 +54,7 @@ const ( ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override" ResourceTypeChat ResourceType = "chat" ResourceTypeMCPServerConfig ResourceType = "mcp_server_config" + ResourceTypeChatModelConfig ResourceType = "chat_model_config" ResourceTypeUserSecret ResourceType = "user_secret" ResourceTypeUserSkill ResourceType = "user_skill" ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" @@ -133,6 +134,8 @@ func (r ResourceType) FriendlyString() string { return "chat" case ResourceTypeMCPServerConfig: return "mcp server config" + case ResourceTypeChatModelConfig: + return "chat model config" case ResourceTypeUserSecret: return "user secret" case ResourceTypeUserSkill: diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 7858d9558f3..4614e43942e 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -17,6 +17,7 @@ const ( ResourceBoundaryLog RBACResource = "boundary_log" ResourceBoundaryUsage RBACResource = "boundary_usage" ResourceChat RBACResource = "chat" + ResourceChatModelConfig RBACResource = "chat_model_config" ResourceConnectionLog RBACResource = "connection_log" ResourceCryptoKey RBACResource = "crypto_key" ResourceDebugInfo RBACResource = "debug_info" @@ -97,6 +98,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceBoundaryLog: {ActionCreate, ActionDelete, ActionRead}, ResourceBoundaryUsage: {ActionDelete, ActionRead, ActionUpdate}, ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate}, + ResourceChatModelConfig: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate}, ResourceConnectionLog: {ActionRead, ActionUpdate}, ResourceCryptoKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceDebugInfo: {ActionRead}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index ca5ebbbd51a..35c55be05a0 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -198,10 +198,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -331,10 +331,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -464,10 +464,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -559,10 +559,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -965,9 +965,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | 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 97c7aa66928..d9bb0184714 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1404,9 +1404,9 @@ None #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_gateway_key:update`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `mcp_server_config:*`, `mcp_server_config:create`, `mcp_server_config:delete`, `mcp_server_config:read`, `mcp_server_config:share`, `mcp_server_config:update`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_build_orchestration:*`, `workspace_build_orchestration:create`, `workspace_build_orchestration:delete`, `workspace_build_orchestration:read`, `workspace_build_orchestration:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_gateway_key:update`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `chat_model_config:*`, `chat_model_config:create`, `chat_model_config:delete`, `chat_model_config:read`, `chat_model_config:share`, `chat_model_config:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `mcp_server_config:*`, `mcp_server_config:create`, `mcp_server_config:delete`, `mcp_server_config:read`, `mcp_server_config:share`, `mcp_server_config:update`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_build_orchestration:*`, `workspace_build_orchestration:create`, `workspace_build_orchestration:delete`, `workspace_build_orchestration:read`, `workspace_build_orchestration:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -11472,9 +11472,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig @@ -11690,9 +11690,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `mcp_server_config`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `chat_model_config`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `mcp_server_config`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index bb855c7f732..b78265b0945 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -870,11 +870,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `chat_model_config`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `mcp_server_config`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_build_orchestration`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index a7d5fb2799f..0911c135fe1 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -73,6 +73,13 @@ export const RBACResourceActions: Partial< share: "share a chat with other users or groups", update: "update chat title or settings", }, + chat_model_config: { + create: "create a new chat model config", + delete: "delete a chat model config", + read: "read chat model configs", + share: "share a chat model config with other users or groups", + update: "update a chat model config", + }, connection_log: { read: "read connection logs", update: "upsert connection log entries", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2e693d2cfc3..dba610ef882 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -707,6 +707,12 @@ export type APIKeyScope = | "chat:*" | "chat:create" | "chat:delete" + | "chat_model_config:*" + | "chat_model_config:create" + | "chat_model_config:delete" + | "chat_model_config:read" + | "chat_model_config:share" + | "chat_model_config:update" | "chat:read" | "chat:share" | "chat:update" @@ -953,6 +959,12 @@ export const APIKeyScopes: APIKeyScope[] = [ "chat:*", "chat:create", "chat:delete", + "chat_model_config:*", + "chat_model_config:create", + "chat_model_config:delete", + "chat_model_config:read", + "chat_model_config:share", + "chat_model_config:update", "chat:read", "chat:share", "chat:update", @@ -7885,6 +7897,7 @@ export type RBACResource = | "boundary_log" | "boundary_usage" | "chat" + | "chat_model_config" | "connection_log" | "crypto_key" | "debug_info" @@ -7939,6 +7952,7 @@ export const RBACResources: RBACResource[] = [ "boundary_log", "boundary_usage", "chat", + "chat_model_config", "connection_log", "crypto_key", "debug_info", @@ -8093,6 +8107,7 @@ export type ResourceType = | "api_key" | "chat" | "chat_instruction_settings" + | "chat_model_config" | "convert_login" | "custom_role" | "git_ssh_key" @@ -8133,6 +8148,7 @@ export const ResourceTypes: ResourceType[] = [ "api_key", "chat", "chat_instruction_settings", + "chat_model_config", "convert_login", "custom_role", "git_ssh_key",