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",