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
+8
View File
@@ -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 {
+12
View File
@@ -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,
+11
View File
@@ -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,
+46
View File
@@ -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{
+17
View File
@@ -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(),
+2
View File
@@ -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{},
},
+29
View File
@@ -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.
+4
View File
@@ -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": {},
@@ -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"))
}
+15
View File
@@ -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,