feat: add chat model config RBAC resource (#27957)

Add the `chat_model_config` RBAC resource, actions, and API key scopes.
This layer includes the scope migration and generated API types. Model
list queries apply token scopes, SQL authorization filters, and row
ACLs.

Write and default-management handlers keep deployment configuration
authorization until the next API layer replaces it.

Depends on #27956

_This pull request description was generated by Coder Agents._
This commit is contained in:
Ethan
2026-08-24 09:43:45 +00:00
committed by GitHub
parent ac41b5b4f9
commit cbe24a3da5
39 changed files with 1190 additions and 149 deletions
+56 -14
View File
@@ -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)
}
+137 -6
View File
@@ -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{})