feat: add group and user ACLs to MCP server configs (#27944)

Adds group and user ACLs to org-scoped MCP server configs so
organizations can restrict specific MCP servers to subsets of members,
mirroring the template ACL pattern.

## Summary

- Migration adds `group_acl`/`user_acl` JSONB columns (nested `{"<id>":
{"permissions": [...]}}` shape) and seeds every existing config with its
organization's Everyone group read entry (the Everyone group ID equals
the org ID), so member access is unchanged by default. Creation seeds
the same entry.
- The blanket org-member read grant from the base PR is replaced by ACL
evaluation: Rego requires org membership for every ACL grant, and
`GetAuthorizedMCPServerConfigs` compiles ACL-aware SQL filters.
- New `ActionShare` (org admins) gates `GET|PATCH
/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl`
(nested under the organization like the rest of the config surface).
PATCH validates principals against the config's organization, merges
sparse updates under a row lock, stamps `updated_by`/`updated_at`, and
is audited as a Write with `Old` captured before authorization. A config
deleted concurrently between the middleware fetch and the locked
re-fetch is concealed as 404, matching the update and delete handlers.
ACL columns are tracked in the audit table.
- ACL management is available in all editions (no enterprise
entitlement), documented in the MCP servers page. Revoking an ACL does
not retro-strip already-selected configs from existing chats; new
selection is blocked at chat create.
- Force On respects the ACL: the forced set is loaded as the chat owner,
so a `force_on` server whose ACL denies the owner never attaches at
create, send, or generation time.
- No rolling-upgrade machinery: upgrades run in scheduled maintenance
downtime, so the migration only backfills existing rows; the API sets
the Everyone read grant explicitly on every insert.

Stacked on #27943. Part of the MCP org-separation stack.

Closes https://linear.app/codercom/issue/CODAGT-712

UAT: verified on a dogfood instance with two members and a custom group:
Everyone-seed default visibility, group grant with Everyone removal
(non-member loses list/fetch/selection), foreign-principal rejection,
non-admin share denial (audited 403 / concealed 404), user_acl restore,
and audited ACL diffs.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
This commit is contained in:
Michael Suchacz
2026-08-19 19:25:18 +00:00
committed by GitHub
parent 299e72ad30
commit 7ca7c30f40
46 changed files with 2114 additions and 132 deletions
+231
View File
@@ -1603,6 +1603,102 @@ const docTemplate = `{
}
}
},
"/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": {
"get": {
"produces": [
"application/json"
],
"tags": [
"MCP"
],
"summary": "Get MCP server config ACL",
"operationId": "get-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "MCP server config ID",
"name": "mcpserverconfig",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.MCPServerConfigACL"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"consumes": [
"application/json"
],
"tags": [
"MCP"
],
"summary": "Update MCP server config ACL",
"operationId": "update-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "MCP server config ID",
"name": "mcpserverconfig",
"in": "path",
"required": true
},
{
"description": "Update MCP server config ACL request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": {
"get": {
"tags": [
@@ -16879,6 +16975,7 @@ const docTemplate = `{
"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",
@@ -17124,6 +17221,7 @@ const docTemplate = `{
"APIKeyScopeMcpServerConfigCreate",
"APIKeyScopeMcpServerConfigDelete",
"APIKeyScopeMcpServerConfigRead",
"APIKeyScopeMcpServerConfigShare",
"APIKeyScopeMcpServerConfigUpdate",
"APIKeyScopeNotificationMessageAll",
"APIKeyScopeNotificationMessageCreate",
@@ -22265,6 +22363,122 @@ const docTemplate = `{
}
}
},
"codersdk.MCPServerConfigACL": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.MCPServerConfigGroup"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.MCPServerConfigUser"
}
}
}
},
"codersdk.MCPServerConfigGroup": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"members": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ReducedUser"
}
},
"name": {
"type": "string"
},
"organization_display_name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_name": {
"type": "string"
},
"quota_allowance": {
"type": "integer"
},
"role": {
"enum": [
"read"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
]
},
"source": {
"$ref": "#/definitions/codersdk.GroupSource"
},
"total_member_count": {
"description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than ` + "`" + `len(Group.Members)` + "`" + `.",
"type": "integer"
}
}
},
"codersdk.MCPServerConfigRole": {
"type": "string",
"enum": [
"read",
""
],
"x-enum-varnames": [
"MCPServerConfigRoleRead",
"MCPServerConfigRoleDeleted"
]
},
"codersdk.MCPServerConfigUser": {
"type": "object",
"required": [
"id",
"username"
],
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"role": {
"enum": [
"read"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
]
},
"username": {
"type": "string"
}
}
},
"codersdk.MCPServerOAuth2DisconnectResponse": {
"type": "object",
"properties": {
@@ -26948,6 +27162,23 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateMCPServerConfigACLRequest": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
},
"user_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
}
}
},
"codersdk.UpdateMCPServerConfigRequest": {
"type": "object",
"properties": {
+213
View File
@@ -1432,6 +1432,94 @@
}
}
},
"/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl": {
"get": {
"produces": ["application/json"],
"tags": ["MCP"],
"summary": "Get MCP server config ACL",
"operationId": "get-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "MCP server config ID",
"name": "mcpserverconfig",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.MCPServerConfigACL"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"consumes": ["application/json"],
"tags": ["MCP"],
"summary": "Update MCP server config ACL",
"operationId": "update-mcp-server-config-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "MCP server config ID",
"name": "mcpserverconfig",
"in": "path",
"required": true
},
{
"description": "Update MCP server config ACL request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateMCPServerConfigACLRequest"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": {
"get": {
"tags": ["MCP"],
@@ -15112,6 +15200,7 @@
"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",
@@ -15357,6 +15446,7 @@
"APIKeyScopeMcpServerConfigCreate",
"APIKeyScopeMcpServerConfigDelete",
"APIKeyScopeMcpServerConfigRead",
"APIKeyScopeMcpServerConfigShare",
"APIKeyScopeMcpServerConfigUpdate",
"APIKeyScopeNotificationMessageAll",
"APIKeyScopeNotificationMessageCreate",
@@ -20283,6 +20373,112 @@
}
}
},
"codersdk.MCPServerConfigACL": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.MCPServerConfigGroup"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.MCPServerConfigUser"
}
}
}
},
"codersdk.MCPServerConfigGroup": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"members": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ReducedUser"
}
},
"name": {
"type": "string"
},
"organization_display_name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_name": {
"type": "string"
},
"quota_allowance": {
"type": "integer"
},
"role": {
"enum": ["read"],
"allOf": [
{
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
]
},
"source": {
"$ref": "#/definitions/codersdk.GroupSource"
},
"total_member_count": {
"description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than `len(Group.Members)`.",
"type": "integer"
}
}
},
"codersdk.MCPServerConfigRole": {
"type": "string",
"enum": ["read", ""],
"x-enum-varnames": [
"MCPServerConfigRoleRead",
"MCPServerConfigRoleDeleted"
]
},
"codersdk.MCPServerConfigUser": {
"type": "object",
"required": ["id", "username"],
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"role": {
"enum": ["read"],
"allOf": [
{
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
]
},
"username": {
"type": "string"
}
}
},
"codersdk.MCPServerOAuth2DisconnectResponse": {
"type": "object",
"properties": {
@@ -24781,6 +24977,23 @@
}
}
},
"codersdk.UpdateMCPServerConfigACLRequest": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
},
"user_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.MCPServerConfigRole"
}
}
}
},
"codersdk.UpdateMCPServerConfigRequest": {
"type": "object",
"properties": {
+12 -5
View File
@@ -1386,11 +1386,18 @@ func New(options *Options) *API {
r.Get("/", api.listMCPServerConfigs)
r.Post("/", api.createMCPServerConfig)
r.Route("/{mcpserverconfig}", func(r chi.Router) {
r.Use(httpmw.ExtractMCPServerConfigParam(options.Database))
r.Get("/", api.getMCPServerConfig)
r.Patch("/", api.updateMCPServerConfig)
r.Delete("/", api.deleteMCPServerConfig)
r.Get("/oauth2/connect", api.mcpServerOAuth2Connect)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionRead, policy.ActionUpdate, policy.ActionDelete)).Get("/", api.getMCPServerConfig)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionUpdate)).Patch("/", api.updateMCPServerConfig)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionDelete)).Delete("/", api.deleteMCPServerConfig)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionShare)).Get("/acl", api.mcpServerConfigACL)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionShare)).Patch("/acl", api.patchMCPServerConfigACL)
r.With(httpmw.ExtractMCPServerConfigParam(options.Database, api.HTTPAuth.Authorize,
policy.ActionRead)).Get("/oauth2/connect", api.mcpServerOAuth2Connect)
})
})
})
+2
View File
@@ -43,7 +43,9 @@ const (
CheckGroupsChatSpendLimitMicrosCheck CheckConstraint = "groups_chat_spend_limit_micros_check" // groups
CheckMcpServerConfigsAuthTypeCheck CheckConstraint = "mcp_server_configs_auth_type_check" // mcp_server_configs
CheckMcpServerConfigsAvailabilityCheck CheckConstraint = "mcp_server_configs_availability_check" // mcp_server_configs
CheckMcpServerConfigsGroupAclIsObject CheckConstraint = "mcp_server_configs_group_acl_is_object" // mcp_server_configs
CheckMcpServerConfigsTransportCheck CheckConstraint = "mcp_server_configs_transport_check" // mcp_server_configs
CheckMcpServerConfigsUserAclIsObject CheckConstraint = "mcp_server_configs_user_acl_is_object" // mcp_server_configs
CheckOauth2ProviderAppCodesScopeNotEmpty CheckConstraint = "oauth2_provider_app_codes_scope_not_empty" // oauth2_provider_app_codes
CheckOauth2ProviderAppTokensScopeNotEmpty CheckConstraint = "oauth2_provider_app_tokens_scope_not_empty" // oauth2_provider_app_tokens
CheckOauth2ProviderAppsClientTypeCheck CheckConstraint = "oauth2_provider_apps_client_type_check" // oauth2_provider_apps
+7
View File
@@ -7677,6 +7677,13 @@ func (q *querier) UpdateMCPServerConfig(ctx context.Context, arg database.Update
return q.db.UpdateMCPServerConfig(ctx, arg)
}
func (q *querier) UpdateMCPServerConfigACLByID(ctx context.Context, arg database.UpdateMCPServerConfigACLByIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateMCPServerConfigACLByIDParams) (database.MCPServerConfig, error) {
return q.db.GetMCPServerConfigByID(ctx, arg.ID)
}
return fetchAndExec(q.log, q.auth, policy.ActionShare, fetch, q.db.UpdateMCPServerConfigACLByID)(ctx, arg)
}
func (q *querier) UpdateMCPServerUserTokenFromRefresh(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return database.MCPServerUserToken{}, err
+7
View File
@@ -1735,6 +1735,13 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().InsertMCPServerConfig(gomock.Any(), arg).Return(config, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceMCPServerConfig.InOrg(arg.OrganizationID), policy.ActionCreate).Returns(config)
}))
s.Run("UpdateMCPServerConfigACLByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
config := testutil.Fake(s.T(), faker, database.MCPServerConfig{})
arg := database.UpdateMCPServerConfigACLByIDParams{ID: config.ID, UpdatedBy: uuid.New()}
dbm.EXPECT().GetMCPServerConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes()
dbm.EXPECT().UpdateMCPServerConfigACLByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(config, policy.ActionShare)
}))
s.Run("UpdateChatMCPServerIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatMCPServerIDsParams{
+13
View File
@@ -347,6 +347,17 @@ func MCPServerConfig(t testing.TB, db database.Store, seed database.MCPServerCon
updatedBy = createdBy
}
groupACL := seed.GroupACL
if groupACL == nil {
groupACL = database.ChatACL{
organizationID.String(): {Permissions: []policy.Action{policy.ActionRead}},
}
}
userACL := seed.UserACL
if userACL == nil {
userACL = database.ChatACL{}
}
cfg, err := db.InsertMCPServerConfig(genCtx, database.InsertMCPServerConfigParams{
ID: takeFirst(seed.ID, uuid.New()),
OrganizationID: organizationID,
@@ -376,6 +387,8 @@ func MCPServerConfig(t testing.TB, db database.Store, seed database.MCPServerCon
ModelIntent: seed.ModelIntent,
AllowInPlanMode: seed.AllowInPlanMode,
ForwardCoderHeaders: seed.ForwardCoderHeaders,
GroupACL: groupACL,
UserACL: userACL,
CreatedBy: createdBy,
UpdatedBy: updatedBy,
})
+8
View File
@@ -5449,6 +5449,14 @@ func (m queryMetricsStore) UpdateMCPServerConfig(ctx context.Context, arg databa
return r0, r1
}
func (m queryMetricsStore) UpdateMCPServerConfigACLByID(ctx context.Context, arg database.UpdateMCPServerConfigACLByIDParams) error {
start := time.Now()
r0 := m.s.UpdateMCPServerConfigACLByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateMCPServerConfigACLByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateMCPServerConfigACLByID").Inc()
return r0
}
func (m queryMetricsStore) UpdateMCPServerUserTokenFromRefresh(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) {
start := time.Now()
r0, r1 := m.s.UpdateMCPServerUserTokenFromRefresh(ctx, arg)
+14
View File
@@ -10310,6 +10310,20 @@ func (mr *MockStoreMockRecorder) UpdateMCPServerConfig(ctx, arg any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMCPServerConfig", reflect.TypeOf((*MockStore)(nil).UpdateMCPServerConfig), ctx, arg)
}
// UpdateMCPServerConfigACLByID mocks base method.
func (m *MockStore) UpdateMCPServerConfigACLByID(ctx context.Context, arg database.UpdateMCPServerConfigACLByIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateMCPServerConfigACLByID", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateMCPServerConfigACLByID indicates an expected call of UpdateMCPServerConfigACLByID.
func (mr *MockStoreMockRecorder) UpdateMCPServerConfigACLByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMCPServerConfigACLByID", reflect.TypeOf((*MockStore)(nil).UpdateMCPServerConfigACLByID), ctx, arg)
}
// UpdateMCPServerUserTokenFromRefresh mocks base method.
func (m *MockStore) UpdateMCPServerUserTokenFromRefresh(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) {
m.ctrl.T.Helper()
+7 -2
View File
@@ -278,7 +278,8 @@ CREATE TYPE api_key_scope AS ENUM (
'mcp_server_config:create',
'mcp_server_config:read',
'mcp_server_config:update',
'mcp_server_config:delete'
'mcp_server_config:delete',
'mcp_server_config:share'
);
CREATE TYPE app_sharing_level AS ENUM (
@@ -2521,9 +2522,13 @@ CREATE TABLE mcp_server_configs (
forward_coder_headers boolean DEFAULT false NOT NULL,
oauth2_revocation_url text DEFAULT ''::text NOT NULL,
organization_id uuid NOT NULL,
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT mcp_server_configs_auth_type_check CHECK ((auth_type = ANY (ARRAY['none'::text, 'oauth2'::text, 'api_key'::text, 'custom_headers'::text, 'user_oidc'::text]))),
CONSTRAINT mcp_server_configs_availability_check CHECK ((availability = ANY (ARRAY['force_on'::text, 'default_on'::text, 'default_off'::text]))),
CONSTRAINT mcp_server_configs_transport_check CHECK ((transport = ANY (ARRAY['streamable_http'::text, 'sse'::text])))
CONSTRAINT mcp_server_configs_group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
CONSTRAINT mcp_server_configs_transport_check CHECK ((transport = ANY (ARRAY['streamable_http'::text, 'sse'::text]))),
CONSTRAINT mcp_server_configs_user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
);
CREATE TABLE mcp_server_user_tokens (
@@ -0,0 +1,6 @@
ALTER TABLE mcp_server_configs
DROP COLUMN group_acl,
DROP COLUMN user_acl;
-- Enum values cannot be removed safely from api_key_scope, so the
-- share scope added by the up migration stays.
@@ -0,0 +1,13 @@
ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'mcp_server_config:share';
ALTER TABLE mcp_server_configs
ADD COLUMN group_acl JSONB NOT NULL DEFAULT '{}'::JSONB,
ADD COLUMN user_acl JSONB NOT NULL DEFAULT '{}'::JSONB,
ADD CONSTRAINT mcp_server_configs_group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
ADD CONSTRAINT mcp_server_configs_user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object');
UPDATE mcp_server_configs
SET group_acl = jsonb_build_object(
organization_id::text,
jsonb_build_object('permissions', jsonb_build_array('read'))
);
@@ -3239,3 +3239,93 @@ func TestMigration000574MCPServerConfigsOrganizationID(t *testing.T) {
require.NoError(t, err)
require.Zero(t, danglingIDs)
}
func TestMigration000576MCPServerConfigACL(t *testing.T) {
t.Parallel()
const priorMigrationVersion = 575
sqlDB := testSQLDB(t)
next, err := migrations.Stepper(sqlDB)
require.NoError(t, err)
for {
version, more, err := next()
require.NoError(t, err)
if !more {
t.Fatalf("migration %d not found", priorMigrationVersion)
}
if version == priorMigrationVersion {
break
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
now := time.Now().UTC().Truncate(time.Microsecond)
var defaultOrgID uuid.UUID
err = sqlDB.QueryRowContext(ctx, `SELECT id FROM organizations WHERE is_default = true`).Scan(&defaultOrgID)
require.NoError(t, err)
orgID := uuid.New()
_, err = sqlDB.ExecContext(ctx, `
INSERT INTO organizations (
id, name, display_name, description, icon, created_at, updated_at,
is_default, deleted, default_org_member_roles
) VALUES ($1, 'migration-570-org', 'Migration 570 Org', '', '', $2, $2, false, false, '{}')
`, orgID, now)
require.NoError(t, err)
configIDs := []uuid.UUID{uuid.New(), uuid.New()}
orgIDs := []uuid.UUID{defaultOrgID, orgID}
for i, configID := range configIDs {
_, err = sqlDB.ExecContext(ctx, `
INSERT INTO mcp_server_configs (
id, organization_id, display_name, slug, description, url, auth_type,
availability, enabled, created_at, updated_at
) VALUES ($1, $2, $3, $4, 'unchanged', $5, 'none', 'default_on', true, $6, $6)
`, configID, orgIDs[i], fmt.Sprintf("Migration 570 Config %d", i), fmt.Sprintf("migration-570-config-%d", i), fmt.Sprintf("https://mcp.example.com/%d", i), now)
require.NoError(t, err)
}
version, _, err := next()
require.NoError(t, err)
require.EqualValues(t, 576, version)
for i, configID := range configIDs {
var displayName, description string
var enabled bool
var groupACL, userACL string
err = sqlDB.QueryRowContext(ctx, `
SELECT display_name, description, enabled, group_acl::text, user_acl::text
FROM mcp_server_configs WHERE id = $1
`, configID).Scan(&displayName, &description, &enabled, &groupACL, &userACL)
require.NoError(t, err)
require.Equal(t, fmt.Sprintf("Migration 570 Config %d", i), displayName)
require.Equal(t, "unchanged", description)
require.True(t, enabled)
require.JSONEq(t, fmt.Sprintf(`{%q:{"permissions":["read"]}}`, orgIDs[i].String()), groupACL)
require.JSONEq(t, `{}`, userACL)
}
for _, column := range []string{"group_acl", "user_acl"} {
var defaultValue string
err = sqlDB.QueryRowContext(ctx, `
SELECT column_default FROM information_schema.columns
WHERE table_name = 'mcp_server_configs' AND column_name = $1
`, column).Scan(&defaultValue)
require.NoError(t, err)
require.Equal(t, "'{}'::jsonb", defaultValue)
}
downSQL, err := os.ReadFile("000576_mcp_server_config_acl.down.sql")
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, string(downSQL))
require.NoError(t, err)
var aclColumns int
err = sqlDB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'mcp_server_configs' AND column_name IN ('group_acl', 'user_acl')
`).Scan(&aclColumns)
require.NoError(t, err)
require.Zero(t, aclColumns)
}
@@ -0,0 +1,17 @@
INSERT INTO mcp_server_configs (
id,
organization_id,
display_name,
slug,
url,
auth_type,
group_acl
) VALUES (
'f5690000-0000-4000-8000-000000000001',
'f5610000-0000-4000-8000-000000000001',
'Fixture MCP ACL Server',
'fixture-mcp-acl-server',
'https://mcp.example.com/acl',
'none',
'{"f5610000-0000-4000-8000-000000000001":{"permissions":["read"]}}'::JSONB
);
+3 -1
View File
@@ -230,7 +230,9 @@ func (c Chat) RBACObject() rbac.Object {
func (m MCPServerConfig) RBACObject() rbac.Object {
return rbac.ResourceMCPServerConfig.
WithID(m.ID).
InOrg(m.OrganizationID)
InOrg(m.OrganizationID).
WithGroupACL(m.GroupACL.RBACACL()).
WithACLUserList(m.UserACL.RBACACL())
}
func (c Chat) IsSubChat() bool {
+3 -1
View File
@@ -1207,7 +1207,7 @@ type mcpServerConfigQuerier interface {
func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, organizationID uuid.UUID, prepared rbac.PreparedAuthorized) ([]MCPServerConfig, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.MCPServerConfigNoACLConverter(),
VariableConverter: regosql.MCPServerConfigConverter(),
})
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
@@ -1261,6 +1261,8 @@ func (q *sqlQuerier) GetAuthorizedMCPServerConfigs(ctx context.Context, organiza
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
); err != nil {
return nil, err
}
+6 -1
View File
@@ -470,6 +470,7 @@ const (
ApiKeyScopeMcpServerConfigRead APIKeyScope = "mcp_server_config:read"
ApiKeyScopeMcpServerConfigUpdate APIKeyScope = "mcp_server_config:update"
ApiKeyScopeMcpServerConfigDelete APIKeyScope = "mcp_server_config:delete"
ApiKeyScopeMcpServerConfigShare APIKeyScope = "mcp_server_config:share"
)
func (e *APIKeyScope) Scan(src interface{}) error {
@@ -749,7 +750,8 @@ func (e APIKeyScope) Valid() bool {
ApiKeyScopeMcpServerConfigCreate,
ApiKeyScopeMcpServerConfigRead,
ApiKeyScopeMcpServerConfigUpdate,
ApiKeyScopeMcpServerConfigDelete:
ApiKeyScopeMcpServerConfigDelete,
ApiKeyScopeMcpServerConfigShare:
return true
}
return false
@@ -998,6 +1000,7 @@ func AllAPIKeyScopeValues() []APIKeyScope {
ApiKeyScopeMcpServerConfigRead,
ApiKeyScopeMcpServerConfigUpdate,
ApiKeyScopeMcpServerConfigDelete,
ApiKeyScopeMcpServerConfigShare,
}
}
@@ -5474,6 +5477,8 @@ type MCPServerConfig struct {
ForwardCoderHeaders bool `db:"forward_coder_headers" json:"forward_coder_headers"`
OAuth2RevocationURL string `db:"oauth2_revocation_url" json:"oauth2_revocation_url"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
}
type MCPServerUserToken struct {
+1
View File
@@ -1505,6 +1505,7 @@ type sqlcQuerier interface {
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
UpdateInboxNotificationReadStatus(ctx context.Context, arg UpdateInboxNotificationReadStatusParams) error
UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPServerConfigParams) (MCPServerConfig, error)
UpdateMCPServerConfigACLByID(ctx context.Context, arg UpdateMCPServerConfigACLByIDParams) error
// Refresh persistence must not recreate a token deleted by disconnect.
// The optimistic lock also prevents stale refreshes from replacing newer tokens.
UpdateMCPServerUserTokenFromRefresh(ctx context.Context, arg UpdateMCPServerUserTokenFromRefreshParams) (MCPServerUserToken, error)
+65 -11
View File
@@ -17147,7 +17147,7 @@ func (q *sqlQuerier) DeleteMCPServerUserTokensByConfigID(ctx context.Context, mc
const getEnabledMCPServerConfigsByOrganization = `-- name: GetEnabledMCPServerConfigsByOrganization :many
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17199,6 +17199,8 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganization(ctx context.Contex
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
); err != nil {
return nil, err
}
@@ -17215,7 +17217,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganization(ctx context.Contex
const getEnabledMCPServerConfigsByOrganizationAndIDs = `-- name: GetEnabledMCPServerConfigsByOrganizationAndIDs :many
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17273,6 +17275,8 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
); err != nil {
return nil, err
}
@@ -17289,7 +17293,7 @@ func (q *sqlQuerier) GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx context.
const getForcedMCPServerConfigsByOrganization = `-- name: GetForcedMCPServerConfigsByOrganization :many
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17342,6 +17346,8 @@ func (q *sqlQuerier) GetForcedMCPServerConfigsByOrganization(ctx context.Context
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
); err != nil {
return nil, err
}
@@ -17358,7 +17364,7 @@ func (q *sqlQuerier) GetForcedMCPServerConfigsByOrganization(ctx context.Context
const getMCPServerConfigByID = `-- name: GetMCPServerConfigByID :one
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17401,13 +17407,15 @@ func (q *sqlQuerier) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const getMCPServerConfigByIDForUpdate = `-- name: GetMCPServerConfigByIDForUpdate :one
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17451,13 +17459,15 @@ func (q *sqlQuerier) GetMCPServerConfigByIDForUpdate(ctx context.Context, id uui
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const getMCPServerConfigByOrganizationAndSlug = `-- name: GetMCPServerConfigByOrganizationAndSlug :one
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17506,13 +17516,15 @@ func (q *sqlQuerier) GetMCPServerConfigByOrganizationAndSlug(ctx context.Context
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const getMCPServerConfigsByOrganization = `-- name: GetMCPServerConfigsByOrganization :many
SELECT
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
FROM
mcp_server_configs
WHERE
@@ -17565,6 +17577,8 @@ func (q *sqlQuerier) GetMCPServerConfigsByOrganization(ctx context.Context, orga
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
); err != nil {
return nil, err
}
@@ -17689,6 +17703,8 @@ INSERT INTO mcp_server_configs (
model_intent,
allow_in_plan_mode,
forward_coder_headers,
group_acl,
user_acl,
created_by,
updated_by
) VALUES (
@@ -17720,11 +17736,13 @@ INSERT INTO mcp_server_configs (
$26::boolean,
$27::boolean,
$28::boolean,
$29::uuid,
$30::uuid
$29,
$30,
$31::uuid,
$32::uuid
)
RETURNING
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
`
type InsertMCPServerConfigParams struct {
@@ -17756,6 +17774,8 @@ type InsertMCPServerConfigParams struct {
ModelIntent bool `db:"model_intent" json:"model_intent"`
AllowInPlanMode bool `db:"allow_in_plan_mode" json:"allow_in_plan_mode"`
ForwardCoderHeaders bool `db:"forward_coder_headers" json:"forward_coder_headers"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
UpdatedBy uuid.UUID `db:"updated_by" json:"updated_by"`
}
@@ -17790,6 +17810,8 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer
arg.ModelIntent,
arg.AllowInPlanMode,
arg.ForwardCoderHeaders,
arg.GroupACL,
arg.UserACL,
arg.CreatedBy,
arg.UpdatedBy,
)
@@ -17827,6 +17849,8 @@ func (q *sqlQuerier) InsertMCPServerConfig(ctx context.Context, arg InsertMCPSer
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
@@ -17914,7 +17938,7 @@ SET
WHERE
id = $28::uuid
RETURNING
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id
id, display_name, slug, description, icon_url, transport, url, auth_type, oauth2_client_id, oauth2_client_secret, oauth2_client_secret_key_id, oauth2_auth_url, oauth2_token_url, oauth2_scopes, api_key_header, api_key_value, api_key_value_key_id, custom_headers, custom_headers_key_id, tool_allow_list, tool_deny_list, availability, enabled, created_by, updated_by, created_at, updated_at, model_intent, allow_in_plan_mode, forward_coder_headers, oauth2_revocation_url, organization_id, group_acl, user_acl
`
type UpdateMCPServerConfigParams struct {
@@ -18013,10 +18037,40 @@ func (q *sqlQuerier) UpdateMCPServerConfig(ctx context.Context, arg UpdateMCPSer
&i.ForwardCoderHeaders,
&i.OAuth2RevocationURL,
&i.OrganizationID,
&i.GroupACL,
&i.UserACL,
)
return i, err
}
const updateMCPServerConfigACLByID = `-- name: UpdateMCPServerConfigACLByID :exec
UPDATE mcp_server_configs
SET
group_acl = $1,
user_acl = $2,
updated_by = $3::uuid,
updated_at = NOW()
WHERE
id = $4::uuid
`
type UpdateMCPServerConfigACLByIDParams struct {
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
UpdatedBy uuid.UUID `db:"updated_by" json:"updated_by"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateMCPServerConfigACLByID(ctx context.Context, arg UpdateMCPServerConfigACLByIDParams) error {
_, err := q.db.ExecContext(ctx, updateMCPServerConfigACLByID,
arg.GroupACL,
arg.UserACL,
arg.UpdatedBy,
arg.ID,
)
return err
}
const updateMCPServerUserTokenFromRefresh = `-- name: UpdateMCPServerUserTokenFromRefresh :one
UPDATE mcp_server_user_tokens
SET
@@ -101,6 +101,8 @@ INSERT INTO mcp_server_configs (
model_intent,
allow_in_plan_mode,
forward_coder_headers,
group_acl,
user_acl,
created_by,
updated_by
) VALUES (
@@ -132,6 +134,8 @@ INSERT INTO mcp_server_configs (
@model_intent::boolean,
@allow_in_plan_mode::boolean,
@forward_coder_headers::boolean,
@group_acl,
@user_acl,
@created_by::uuid,
@updated_by::uuid
)
@@ -175,6 +179,16 @@ WHERE
RETURNING
*;
-- name: UpdateMCPServerConfigACLByID :exec
UPDATE mcp_server_configs
SET
group_acl = @group_acl,
user_acl = @user_acl,
updated_by = @updated_by::uuid,
updated_at = NOW()
WHERE
id = @id::uuid;
-- name: DeleteMCPServerConfigByID :exec
DELETE FROM
mcp_server_configs
+6
View File
@@ -83,6 +83,12 @@ sql:
- column: "chats.group_acl"
go_type:
type: "ChatACL"
- column: "mcp_server_configs.user_acl"
go_type:
type: "ChatACL"
- column: "mcp_server_configs.group_acl"
go_type:
type: "ChatACL"
- column: "chats_expanded.user_acl"
go_type:
type: "ChatACL"
+22 -5
View File
@@ -5,7 +5,10 @@ import (
"net/http"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
@@ -22,9 +25,13 @@ func MCPServerConfigParam(r *http.Request) database.MCPServerConfig {
}
// ExtractMCPServerConfigParam reads the "mcpserverconfig" URL parameter.
// Unauthorized reads are concealed as not found, so denied and missing rows
// both return 404.
func ExtractMCPServerConfigParam(db database.Store) func(http.Handler) http.Handler {
// Callers with none of the admitted actions are concealed as not found, so
// denied and missing rows both return 404.
func ExtractMCPServerConfigParam(
db database.Store,
auth func(r *http.Request, action policy.Action, object rbac.Objecter) bool,
actions ...policy.Action,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -33,7 +40,10 @@ func ExtractMCPServerConfigParam(db database.Store) func(http.Handler) http.Hand
return
}
config, err := db.GetMCPServerConfigByID(ctx, configID)
// Authorization follows the raw lookup because mutation-only callers
// may lack the read access enforced by the database wrapper.
//nolint:gocritic // The explicit action checks below own authorization.
config, err := db.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), configID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
@@ -45,7 +55,14 @@ func ExtractMCPServerConfigParam(db database.Store) func(http.Handler) http.Hand
})
return
}
if config.OrganizationID != OrganizationParam(r).ID {
admitted := false
for _, action := range actions {
if auth(r, action, config) {
admitted = true
break
}
}
if config.OrganizationID != OrganizationParam(r).ID || !admitted {
httpapi.ResourceNotFound(rw)
return
}
+80 -20
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -161,22 +162,54 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
// Full view: disabled configs included, management fields unredacted.
// Auditors get it to inspect audit-logged resources; their MCP config
// read grant cannot select it because members hold the same read.
// Other members see enabled configs with management fields redacted.
// Full view: disabled configs included, management fields unredacted,
// fetched with system access behind this gate. Auditors get it to
// inspect audit-logged resources but stay subject to per-server ACLs
// elsewhere. Other members see enabled ACL-granted configs, redacted.
// The update leg also requires config read so a custom role granting
// update without read cannot lift the read filtering below.
hasFullView := (api.Authorize(r, policy.ActionRead, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) &&
hasFullView := ((api.Authorize(r, policy.ActionRead, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) &&
api.Authorize(r, policy.ActionUpdate, rbac.ResourceMCPServerConfig.InOrg(organization.ID))) ||
api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(organization.ID))
api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(organization.ID))) &&
api.mcpServerConfigReadInKeyScope(r, organization.ID)
var configs []database.MCPServerConfig
var err error
if hasFullView {
configs, err = api.Database.GetMCPServerConfigsByOrganization(ctx, organization.ID)
} else {
configs, err = api.Database.GetEnabledMCPServerConfigsByOrganization(ctx, organization.ID)
//nolint:gocritic // The update-or-audit gate above owns this authorization.
configs, err = api.Database.GetMCPServerConfigsByOrganization(dbauthz.AsSystemRestricted(ctx), organization.ID)
} else if api.mcpServerConfigReadInKeyScope(r, organization.ID) {
seen := make(map[uuid.UUID]struct{})
for _, action := range []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare} {
prepared, prepareErr := api.HTTPAuth.AuthorizeSQLFilter(r, action, rbac.ResourceMCPServerConfig.Type)
if prepareErr != nil {
httpapi.InternalServerError(rw, prepareErr)
return
}
authorized, queryErr := api.Database.GetAuthorizedMCPServerConfigs(ctx, organization.ID, prepared)
if queryErr != nil {
err = queryErr
break
}
for _, config := range authorized {
if _, ok := seen[config.ID]; ok {
continue
}
seen[config.ID] = struct{}{}
configs = append(configs, config)
}
}
slices.SortFunc(configs, func(a, b database.MCPServerConfig) int {
return strings.Compare(a.DisplayName, b.DisplayName)
})
// Management-authorized callers keep disabled configs in the redacted
// list so they can still reach and manage them.
configs = slices.DeleteFunc(configs, func(config database.MCPServerConfig) bool {
return !config.Enabled &&
!api.Authorize(r, policy.ActionUpdate, config) &&
!api.Authorize(r, policy.ActionDelete, config) &&
!api.Authorize(r, policy.ActionShare, config)
})
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -231,6 +264,24 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// mcpServerConfigReadInKeyScope reports whether the caller's API key scope
// permits reading MCP server configs. The full-view list fetch bypasses row
// authorization with system access, so the key's scope must be enforced here.
// Owner roles make the authorize outcome depend only on the scope dimension.
func (api *API) mcpServerConfigReadInKeyScope(r *http.Request, organizationID uuid.UUID) bool {
caller := httpmw.UserAuthorization(r.Context())
scopeOnly := rbac.Subject{
Type: caller.Type,
FriendlyName: caller.FriendlyName,
ID: caller.ID,
Roles: rbac.RoleIdentifiers{rbac.RoleOwner()},
Scope: caller.Scope,
}
err := api.HTTPAuth.Authorizer.Authorize(r.Context(), scopeOnly,
policy.ActionRead, rbac.ResourceMCPServerConfig.InOrg(organizationID))
return err == nil
}
// @Summary Create MCP server config
// @ID create-mcp-server-config
// @Security CoderSessionToken
@@ -425,8 +476,12 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ModelIntent: req.ModelIntent,
AllowInPlanMode: req.AllowInPlanMode,
ForwardCoderHeaders: req.ForwardCoderHeaders,
CreatedBy: apiKey.UserID,
UpdatedBy: apiKey.UserID,
GroupACL: database.ChatACL{
organization.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
},
UserACL: database.ChatACL{},
CreatedBy: apiKey.UserID,
UpdatedBy: apiKey.UserID,
})
if err != nil {
switch {
@@ -473,11 +528,16 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
config := httpmw.MCPServerConfigParam(r)
if !api.mcpServerConfigReadInKeyScope(r, config.OrganizationID) {
httpapi.ResourceNotFound(rw)
return
}
// Same full-view rule as listMCPServerConfigs: admins and auditors.
hasFullView := api.Authorize(r, policy.ActionUpdate, config) ||
api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(config.OrganizationID))
if !hasFullView && !config.Enabled {
if !hasFullView && !config.Enabled &&
!api.Authorize(r, policy.ActionDelete, config) {
httpapi.ResourceNotFound(rw)
return
}
@@ -565,9 +625,8 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
})
defer commitAudit()
// Set Old before the write-authz check so a write-denied 403 is
// audited. Read-denied callers were already concealed with 404 by
// the param middleware and never reach this handler.
// Set Old before the requested-action check so callers admitted for a
// different action are audited when this handler denies them with 403.
aReq.Old = httpmw.MCPServerConfigParam(r)
aReq.UpdateOrganizationID(aReq.Old.OrganizationID)
@@ -623,7 +682,8 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
// Lock and re-fetch the row so omitted fields and the audit baseline
// match the row this update replaces, and so grant invalidation
// serializes with in-flight OAuth callbacks verifying the config.
current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, existing.ID)
//nolint:gocritic // The update write reauthorizes the locked row.
current, err := tx.GetMCPServerConfigByIDForUpdate(dbauthz.AsSystemRestricted(ctx), existing.ID)
if err != nil {
return err
}
@@ -927,9 +987,8 @@ func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
})
defer commitAudit()
// Set Old before the write-authz check so a write-denied 403 is
// audited. Read-denied callers were already concealed with 404 by
// the param middleware and never reach this handler.
// Set Old before the requested-action check so callers admitted for a
// different action are audited when this handler denies them with 403.
aReq.Old = httpmw.MCPServerConfigParam(r)
aReq.UpdateOrganizationID(aReq.Old.OrganizationID)
@@ -942,7 +1001,8 @@ func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
// Re-fetch under a row lock so the audit record describes the
// row this request actually removes, not a middleware snapshot
// that a concurrent update may have made stale.
current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, config.ID)
//nolint:gocritic // The delete write reauthorizes the locked row.
current, err := tx.GetMCPServerConfigByIDForUpdate(dbauthz.AsSystemRestricted(ctx), config.ID)
if err != nil {
return err
}
+330
View File
@@ -0,0 +1,330 @@
package coderd
import (
"context"
"fmt"
"maps"
"net/http"
"slices"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac/acl"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Get MCP server config ACL
// @ID get-mcp-server-config-acl
// @Security CoderSessionToken
// @Tags MCP
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Param mcpserverconfig path string true "MCP server config ID" format(uuid)
// @Success 200 {object} codersdk.MCPServerConfigACL
// @Router /api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl [get]
// @x-apidocgen {"skip": true}
func (api *API) mcpServerConfigACL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
config := httpmw.MCPServerConfigParam(r)
// The read gate admits every ACL-granted member, so gate ACL
// enumeration on the same share permission that gates updates.
if !api.Authorize(r, policy.ActionShare, config.RBACObject()) {
httpapi.Forbidden(rw)
return
}
users, ok := api.mcpServerConfigACLUsers(ctx, rw, config.UserACL)
if !ok {
return
}
groups, ok := api.mcpServerConfigACLGroups(ctx, rw, config.GroupACL)
if !ok {
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerConfigACL{
Users: users,
Groups: groups,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Update MCP server config ACL
// @ID update-mcp-server-config-acl
// @Security CoderSessionToken
// @Tags MCP
// @Accept json
// @Param organization path string true "Organization ID" format(uuid)
// @Param mcpserverconfig path string true "MCP server config ID" format(uuid)
// @Param request body codersdk.UpdateMCPServerConfigACLRequest true "Update MCP server config ACL request"
// @Success 204
// @Router /api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl [patch]
// @x-apidocgen {"skip": true}
func (api *API) patchMCPServerConfigACL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
config := httpmw.MCPServerConfigParam(r)
apiKey := httpmw.APIKey(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: config.OrganizationID,
})
defer commitAudit()
aReq.Old = config
if !api.Authorize(r, policy.ActionShare, config.RBACObject()) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateMCPServerConfigACLRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
validations := acl.Validate(ctx, api.Database, MCPServerConfigACLUpdateValidator(req))
validations = append(validations, api.validateMCPServerConfigACLOrganization(ctx, config.OrganizationID, req)...)
userRoles, dupErrs := canonicalMCPServerConfigACLRoles("user_roles", req.UserRoles)
validations = append(validations, dupErrs...)
groupRoles, dupErrs := canonicalMCPServerConfigACLRoles("group_roles", req.GroupRoles)
validations = append(validations, dupErrs...)
if len(validations) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request to update MCP server config ACL.",
Validations: validations,
})
return
}
var updated database.MCPServerConfig
err := api.Database.InTx(func(tx database.Store) error {
//nolint:gocritic // The ACL write below reauthorizes the locked row for share.
current, err := tx.GetMCPServerConfigByIDForUpdate(dbauthz.AsSystemRestricted(ctx), config.ID)
if err != nil {
return xerrors.Errorf("get MCP server config for update: %w", err)
}
aReq.Old = current
userACL := maps.Clone(current.UserACL)
groupACL := maps.Clone(current.GroupACL)
applyMCPServerConfigACLRoles(userACL, userRoles)
applyMCPServerConfigACLRoles(groupACL, groupRoles)
if err := tx.UpdateMCPServerConfigACLByID(ctx, database.UpdateMCPServerConfigACLByIDParams{
ID: config.ID,
UserACL: userACL,
GroupACL: groupACL,
UpdatedBy: apiKey.UserID,
}); err != nil {
return xerrors.Errorf("update MCP server config ACL: %w", err)
}
updated = current
updated.UserACL = userACL
updated.GroupACL = groupACL
updated.UpdatedBy = uuid.NullUUID{UUID: apiKey.UserID, Valid: true}
return nil
}, nil)
if err != nil {
// A concurrent delete between the middleware fetch and the
// locked re-fetch stays concealed as 404, matching the update
// and delete handlers.
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
aReq.New = updated
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) mcpServerConfigACLUsers(ctx context.Context, rw http.ResponseWriter, entries database.ChatACL) ([]codersdk.MCPServerConfigUser, bool) {
ids := parseMCPServerConfigACLIDs(entries)
//nolint:gocritic // ACL managers may resolve principals after the share gate passes.
users, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), ids)
if err != nil {
httpapi.InternalServerError(rw, err)
return nil, false
}
result := make([]codersdk.MCPServerConfigUser, 0, len(users))
for _, user := range users {
result = append(result, codersdk.MCPServerConfigUser{
MinimalUser: db2sdk.MinimalUser(user),
Role: codersdk.MCPServerConfigRoleRead,
})
}
return result, true
}
func (api *API) mcpServerConfigACLGroups(ctx context.Context, rw http.ResponseWriter, entries database.ChatACL) ([]codersdk.MCPServerConfigGroup, bool) {
ids := parseMCPServerConfigACLIDs(entries)
var groups []database.GetGroupsRow
if len(ids) > 0 {
var err error
//nolint:gocritic // ACL managers may resolve principals after the share gate passes.
groups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: ids})
if err != nil {
httpapi.InternalServerError(rw, err)
return nil, false
}
}
countByGroup := make(map[uuid.UUID]int64, len(groups))
if len(groups) > 0 {
groupIDs := make([]uuid.UUID, 0, len(groups))
for _, group := range groups {
groupIDs = append(groupIDs, group.Group.ID)
}
//nolint:gocritic // ACL managers may resolve group sizes after the share gate passes.
countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{
GroupIds: groupIDs,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return nil, false
}
for _, row := range countRows {
countByGroup[row.GroupID] = row.MemberCount
}
}
result := make([]codersdk.MCPServerConfigGroup, 0, len(groups))
for _, group := range groups {
result = append(result, codersdk.MCPServerConfigGroup{
Group: db2sdk.Group(group, nil, int(countByGroup[group.Group.ID])),
Role: codersdk.MCPServerConfigRoleRead,
})
}
return result, true
}
// canonicalMCPServerConfigACLRoles rekeys the request map by canonical
// uuid.String() values so noncanonical spellings hit the same keys RBAC
// reads, and rejects requests where two spellings collapse to one
// principal because map order would decide which role wins. Unparsable
// keys are skipped; acl.Validate already reports them.
func canonicalMCPServerConfigACLRoles(field string, roles map[string]codersdk.MCPServerConfigRole) (map[string]codersdk.MCPServerConfigRole, []codersdk.ValidationError) {
canonical := make(map[string]codersdk.MCPServerConfigRole, len(roles))
var validErrs []codersdk.ValidationError
for rawID, role := range roles {
parsed, err := uuid.Parse(rawID)
if err != nil {
continue
}
id := parsed.String()
if _, ok := canonical[id]; ok {
validErrs = append(validErrs, codersdk.ValidationError{
Field: field,
Detail: fmt.Sprintf("duplicate entries for ID %s", id),
})
continue
}
canonical[id] = role
}
return canonical, validErrs
}
func applyMCPServerConfigACLRoles(entries database.ChatACL, roles map[string]codersdk.MCPServerConfigRole) {
for id, role := range roles {
if role == codersdk.MCPServerConfigRoleDeleted {
delete(entries, id)
continue
}
entries[id] = database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}}
}
}
func parseMCPServerConfigACLIDs(entries database.ChatACL) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(entries))
for rawID := range entries {
if id, err := uuid.Parse(rawID); err == nil {
ids = append(ids, id)
}
}
return ids
}
func (api *API) validateMCPServerConfigACLOrganization(ctx context.Context, organizationID uuid.UUID, req codersdk.UpdateMCPServerConfigACLRequest) []codersdk.ValidationError {
var validations []codersdk.ValidationError
userIDs := activeMCPServerConfigACLIDs(req.UserRoles)
if len(userIDs) > 0 {
//nolint:gocritic // Principal validation requires organization membership visibility.
memberships, err := api.Database.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
if err != nil {
return append(validations, codersdk.ValidationError{Field: "user_roles", Detail: err.Error()})
}
byUser := make(map[uuid.UUID][]uuid.UUID, len(memberships))
for _, membership := range memberships {
byUser[membership.UserID] = membership.OrganizationIDs
}
for _, id := range userIDs {
if !slices.Contains(byUser[id], organizationID) {
validations = append(validations, codersdk.ValidationError{
Field: "user_roles",
Detail: "user " + id.String() + " does not belong to organization " + organizationID.String(),
})
}
}
}
groupIDs := activeMCPServerConfigACLIDs(req.GroupRoles)
if len(groupIDs) > 0 {
//nolint:gocritic // Principal validation requires group organization visibility.
groups, err := api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
if err != nil {
return append(validations, codersdk.ValidationError{Field: "group_roles", Detail: err.Error()})
}
for _, group := range groups {
if group.Group.OrganizationID != organizationID {
validations = append(validations, codersdk.ValidationError{
Field: "group_roles",
Detail: "group " + group.Group.ID.String() + " does not belong to organization " + organizationID.String(),
})
}
}
}
return validations
}
func activeMCPServerConfigACLIDs(roles map[string]codersdk.MCPServerConfigRole) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(roles))
for rawID, role := range roles {
if role == codersdk.MCPServerConfigRoleDeleted {
continue
}
if id, err := uuid.Parse(rawID); err == nil {
ids = append(ids, id)
}
}
return ids
}
type MCPServerConfigACLUpdateValidator codersdk.UpdateMCPServerConfigACLRequest
var _ acl.UpdateValidator[codersdk.MCPServerConfigRole] = MCPServerConfigACLUpdateValidator{}
func (m MCPServerConfigACLUpdateValidator) Users() (map[string]codersdk.MCPServerConfigRole, string) {
return m.UserRoles, "user_roles"
}
func (m MCPServerConfigACLUpdateValidator) Groups() (map[string]codersdk.MCPServerConfigRole, string) {
return m.GroupRoles, "group_roles"
}
func (MCPServerConfigACLUpdateValidator) ValidateRole(role codersdk.MCPServerConfigRole) error {
if role == codersdk.MCPServerConfigRoleDeleted || role == codersdk.MCPServerConfigRoleRead {
return nil
}
return xerrors.Errorf("role %q is not a valid MCP server config role", role)
}
+251 -25
View File
@@ -533,13 +533,13 @@ func TestMCPServerConfigsAudit(t *testing.T) {
require.True(t, byResourceID[deletedID].IsDeleted)
})
t.Run("WriteDeniedAudited", func(t *testing.T) {
t.Run("WriteDeniedConcealed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
memberClient, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-denied", true)
mAudit.ResetLogs()
@@ -549,41 +549,25 @@ func TestMCPServerConfigsAudit(t *testing.T) {
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, member.ID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
})
t.Run("DeleteDeniedAudited", func(t *testing.T) {
t.Run("DeleteDeniedConcealed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
memberClient, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-denied", true)
mAudit.ResetLogs()
err := memberClient.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionDelete, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, member.ID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
})
}
@@ -641,6 +625,15 @@ func TestMCPServerConfigsNonAdmin(t *testing.T) {
require.Len(t, memberConfigs, 1)
require.Equal(t, "enabled-server", memberConfigs[0].Slug)
// Auditors list ACL-restricted configs but cannot read them row-level.
restricted := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "restricted-server", true)
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, restricted.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
},
})
require.NoError(t, err)
// Auditors need the full management view of the MCP configs their
// audit logs reference.
for name, roles := range map[string][]rbac.RoleIdentifier{
@@ -650,7 +643,7 @@ func TestMCPServerConfigsNonAdmin(t *testing.T) {
auditorClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, roles...)
auditorConfigs, err := auditorClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err, name)
require.Len(t, auditorConfigs, 2, name)
require.Len(t, auditorConfigs, 3, name)
for _, config := range auditorConfigs {
require.NotEmpty(t, config.URL, "%s: %s", name, config.Slug)
if !config.Enabled {
@@ -659,9 +652,242 @@ func TestMCPServerConfigsNonAdmin(t *testing.T) {
require.NotEmpty(t, fetched.URL, name)
}
}
_, err = auditorClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, restricted.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr, name)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), name)
}
}
func TestMCPServerConfigsScopedKeyFullView(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "enabled-server", true)
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "disabled-server", false)
_, auditor := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID,
rbac.ScopedRoleOrgAuditor(firstUser.OrganizationID))
// MCP config scopes are not user-mintable, so seed scoped keys directly.
newScopedClient := func(userID uuid.UUID, scopes ...database.APIKeyScope) *codersdk.Client {
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: userID,
Scopes: append(database.APIKeyScopes{"organization:read"}, scopes...),
})
client := codersdk.New(adminClient.URL)
client.SetSessionToken(token)
return client
}
// A key whose scope covers reading MCP configs keeps the full view.
fullViewConfigs, err := newScopedClient(firstUser.UserID,
"mcp_server_config:update", "mcp_server_config:read").MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, fullViewConfigs, 2)
// Update-or-audit roles make the caller full-view eligible, but a key
// scoped without mcp_server_config:read must not receive config data.
for name, client := range map[string]*codersdk.Client{
"AdminUpdateScope": newScopedClient(firstUser.UserID, "mcp_server_config:update"),
"AuditorAuditScope": newScopedClient(auditor.ID, "audit_log:read"),
} {
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err, name)
require.Empty(t, configs, name)
}
}
func TestMCPServerConfigACL(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Auditor: mAudit,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
groupMemberClient, groupMember := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
nonMemberClient, nonMember := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID})
config := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "acl-server", true)
mAudit.ResetLogs()
err := adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
group.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.NoError(t, err)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, config.ID, logs[0].ResourceID)
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
aclResponse, err := adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Len(t, aclResponse.Groups, 1)
require.Equal(t, group.ID, aclResponse.Groups[0].ID)
require.Equal(t, 1, aclResponse.Groups[0].TotalMemberCount)
configs, err := groupMemberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Len(t, configs, 1)
_, err = groupMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
configs, err = nonMemberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
require.NoError(t, err)
require.Empty(t, configs)
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
_, err = groupMemberClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
mAudit.ResetLogs()
err = groupMemberClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
require.Empty(t, mAudit.AuditLogs())
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
nonMember.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.NoError(t, err)
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
// The sparse user-only update must merge with the existing ACL, not
// clobber the earlier group grant.
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Len(t, aclResponse.Groups, 1)
require.Equal(t, group.ID, aclResponse.Groups[0].ID)
require.Len(t, aclResponse.Users, 1)
require.Equal(t, nonMember.ID, aclResponse.Users[0].ID)
// Conflicting roles under two spellings of one principal are
// ambiguous and must be rejected rather than resolved by map order.
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
nonMember.ID.String(): codersdk.MCPServerConfigRoleRead,
"urn:uuid:" + nonMember.ID.String(): codersdk.MCPServerConfigRoleDeleted,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), "duplicate entries for ID "+nonMember.ID.String())
// Noncanonical UUID spellings pass validation, so grants and
// deletions must canonicalize to hit the same ACL keys RBAC reads.
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
strings.ToUpper(groupMember.ID.String()): codersdk.MCPServerConfigRoleRead,
},
})
require.NoError(t, err)
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Len(t, aclResponse.Users, 2)
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
"urn:uuid:" + nonMember.ID.String(): codersdk.MCPServerConfigRoleDeleted,
strings.ToUpper(groupMember.ID.String()): codersdk.MCPServerConfigRoleDeleted,
},
})
require.NoError(t, err)
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)
require.Empty(t, aclResponse.Users)
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
otherOrg := dbgen.Organization(t, db, database.Organization{})
otherGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrg.ID})
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
otherGroup.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), otherGroup.ID.String())
foreignUser := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: otherOrg.ID, UserID: foreignUser.ID})
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
UserRoles: map[string]codersdk.MCPServerConfigRole{
foreignUser.ID.String(): codersdk.MCPServerConfigRoleRead,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Error(), foreignUser.ID.String())
}
// mcpServerConfigDeleteRaceStore deletes the config right after the param
// middleware read once armed, so the handler's locked re-fetch sees a
// concurrently deleted row.
type mcpServerConfigDeleteRaceStore struct {
database.Store
armed atomic.Bool
}
func (s *mcpServerConfigDeleteRaceStore) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) {
config, err := s.Store.GetMCPServerConfigByID(ctx, id)
if err == nil && s.armed.CompareAndSwap(true, false) {
if err := s.Store.DeleteMCPServerConfigByID(ctx, id); err != nil {
return database.MCPServerConfig{}, err
}
}
return config, err
}
func TestMCPServerConfigACLConcurrentDelete(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
db, ps := dbtestutil.NewDB(t)
store := &mcpServerConfigDeleteRaceStore{Store: db}
adminClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Database: store,
Pubsub: ps,
})
firstUser := coderdtest.CreateFirstUser(t, adminClient)
config := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "acl-delete-race", true)
store.armed.Store(true)
err := adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
}
// TestMCPServerConfigsSecretsNeverLeaked is a load-bearing test that
// ensures secret fields (OAuth2 client secret, API key value, custom
// headers) are never present in API responses for any caller. If this
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/coder/coder/v2/codersdk"
)
type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole | codersdk.ChatRole] interface {
type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole | codersdk.ChatRole | codersdk.MCPServerConfigRole] interface {
// Users should return a map from user UUIDs (as strings) to the role they
// are being assigned. Additionally, it should return a string that will be
// used as the field name for the ValidationErrors returned from Validate.
@@ -25,7 +25,7 @@ type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole | coder
ValidateRole(role Role) error
}
func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole | codersdk.ChatRole](
func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole | codersdk.ChatRole | codersdk.MCPServerConfigRole](
ctx context.Context,
db database.Store,
v UpdateValidator[Role],
+1
View File
@@ -224,6 +224,7 @@ var (
// - "ActionCreate" :: create a new MCP server config
// - "ActionDelete" :: delete MCP server config
// - "ActionRead" :: read MCP server config
// - "ActionShare" :: share MCP server config with other users or groups
// - "ActionUpdate" :: update MCP server config
ResourceMCPServerConfig = Object{
Type: "mcp_server_config",
+1
View File
@@ -89,6 +89,7 @@ var mcpServerConfigActions = map[Action]ActionDefinition{
ActionCreate: "create a new MCP server config",
ActionRead: "read MCP server config",
ActionUpdate: "update MCP server config",
ActionShare: "share MCP server config with other users or groups",
ActionDelete: "delete MCP server config",
}
+10
View File
@@ -268,6 +268,16 @@ func TestRegoQueries(t *testing.T) {
p("user_acl->'me' ? '*'")),
VariableConverter: regosql.DefaultVariableConverter(),
},
{
Name: "MCPServerConfigACLAllow",
Queries: []string{
`"read" in input.object.acl_user_list["d5389ccc-57a4-4b13-8c3f-31747bcdc9f1"]`,
`"read" in input.object.acl_group_list["96c55a0e-73b4-44fc-abac-70d53c35c04c"]`,
},
ExpectedSQL: "((mcp_server_configs.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? 'read')" +
" OR (mcp_server_configs.group_acl#>array['96c55a0e-73b4-44fc-abac-70d53c35c04c', 'permissions'] ? 'read'))",
VariableConverter: regosql.MCPServerConfigConverter(),
},
{
Name: "TemplateOwner",
Queries: []string{
+4 -7
View File
@@ -74,18 +74,15 @@ func ChatNoACLConverter() *sqltypes.VariableConverter {
return matcher
}
// MCPServerConfigNoACLConverter converts MCP server config permissions to SQL.
// Until sharing adds ACL columns, ACL matchers stay false and only organization
// ownership filters rows.
func MCPServerConfigNoACLConverter() *sqltypes.VariableConverter {
func MCPServerConfigConverter() *sqltypes.VariableConverter {
matcher := sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
organizationOwnerMatcher(),
sqltypes.StringVarMatcher("mcp_server_configs.organization_id :: text", []string{"input", "object", "org_owner"}),
sqltypes.AlwaysFalse(userOwnerMatcher()),
)
matcher.RegisterMatcher(
sqltypes.AlwaysFalse(groupACLMatcher(matcher)),
sqltypes.AlwaysFalse(userACLMatcher(matcher)),
ACLMappingMatcher(matcher, "mcp_server_configs.group_acl", []string{"input", "object", "acl_group_list"}).UsingSubfield("permissions"),
ACLMappingMatcher(matcher, "mcp_server_configs.user_acl", []string{"input", "object", "acl_user_list"}).UsingSubfield("permissions"),
)
return matcher
}
-6
View File
@@ -476,7 +476,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Allow auditors to query deployment stats and insights.
ResourceDeploymentStats.Type: {policy.ActionRead},
ResourceDeploymentConfig.Type: {policy.ActionRead},
ResourceMCPServerConfig.Type: {policy.ActionRead},
// Allow auditors to query AI Bridge interceptions.
ResourceAibridgeInterception.Type: {policy.ActionRead},
// Allow auditors to read boundary logs.
@@ -612,7 +611,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
ResourceMCPServerConfig.Type: {policy.ActionRead},
}),
Member: []Permission{},
},
@@ -1159,8 +1157,6 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
ResourceOrganization.Type: {policy.ActionRead},
// Can read available roles.
ResourceAssignOrgRole.Type: {policy.ActionRead},
// TODO(mafredri): remove once CODAGT-712 adds per-config ACL evaluation.
ResourceMCPServerConfig.Type: {policy.ActionRead},
}
// In all modes of workspace sharing but `none`, members need to
@@ -1238,8 +1234,6 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
ResourceOrganization.Type: {policy.ActionRead},
// Can read available roles.
ResourceAssignOrgRole.Type: {policy.ActionRead},
// TODO(mafredri): remove once CODAGT-712 adds per-config ACL evaluation.
ResourceMCPServerConfig.Type: {policy.ActionRead},
}
// When workspace sharing is enabled, service accounts need to see
+8 -6
View File
@@ -825,17 +825,19 @@ func TestRolePermissions(t *testing.T) {
},
},
{
Name: "MCPServerConfigRead",
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID),
Name: "MCPServerConfigRead",
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID).WithGroupACL(map[string][]policy.Action{
orgID.String(): {policy.ActionRead},
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgAuditor, auditor, orgMemberMe},
false: {setOtherOrg, memberMe, agentsAccessUser, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin, templateAdmin, userAdmin},
true: {owner, orgAdmin, orgAuditor, orgMemberMe, agentsAccessUser, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin},
false: {setOtherOrg, memberMe, templateAdmin, userAdmin, auditor},
},
},
{
Name: "MCPServerConfigWrite",
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionShare},
Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
+3
View File
@@ -76,6 +76,7 @@ const (
ScopeMcpServerConfigCreate ScopeName = "mcp_server_config:create"
ScopeMcpServerConfigDelete ScopeName = "mcp_server_config:delete"
ScopeMcpServerConfigRead ScopeName = "mcp_server_config:read"
ScopeMcpServerConfigShare ScopeName = "mcp_server_config:share"
ScopeMcpServerConfigUpdate ScopeName = "mcp_server_config:update"
ScopeNotificationMessageCreate ScopeName = "notification_message:create"
ScopeNotificationMessageDelete ScopeName = "notification_message:delete"
@@ -268,6 +269,7 @@ func (e ScopeName) Valid() bool {
ScopeMcpServerConfigCreate,
ScopeMcpServerConfigDelete,
ScopeMcpServerConfigRead,
ScopeMcpServerConfigShare,
ScopeMcpServerConfigUpdate,
ScopeNotificationMessageCreate,
ScopeNotificationMessageDelete,
@@ -461,6 +463,7 @@ func AllScopeNameValues() []ScopeName {
ScopeMcpServerConfigCreate,
ScopeMcpServerConfigDelete,
ScopeMcpServerConfigRead,
ScopeMcpServerConfigShare,
ScopeMcpServerConfigUpdate,
ScopeNotificationMessageCreate,
ScopeNotificationMessageDelete,
+23 -12
View File
@@ -31,6 +31,7 @@ import (
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
@@ -1203,19 +1204,29 @@ type PromoteQueuedResult struct {
PromotedMessage database.ChatMessage
}
// enforceForcedMCPServerIDs appends the ID of every enabled Force On
// MCP server config in the chat's organization missing from ids. Force
// On availability is a server-side policy: callers must not be able to
// exclude such servers by stripping IDs from a request (Cure53
// CDM-02-010). The forced set is read with daemon scope because
// regular users cannot read MCP server configs directly.
func enforceForcedMCPServerIDs(ctx context.Context, store database.Store, organizationID uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
//nolint:gocritic // Non-admin users need chatd-scoped config reads here.
forced, err := store.GetForcedMCPServerConfigsByOrganization(dbauthz.AsChatd(ctx), organizationID)
// forcedMCPServerConfigsForOwner filters enabled Force On configs
// through the chat owner's ACL so availability cannot widen access.
func forcedMCPServerConfigsForOwner(ctx context.Context, store database.Store, organizationID, ownerID uuid.UUID) ([]database.MCPServerConfig, error) {
owner, _, err := httpmw.UserRBACSubject(ctx, store, ownerID, rbac.ScopeAll)
if err != nil {
return nil, xerrors.Errorf("load chat owner authorization: %w", err)
}
forced, err := store.GetForcedMCPServerConfigsByOrganization(dbauthz.As(ctx, owner), organizationID)
if err != nil {
return nil, xerrors.Errorf("get forced MCP server configs: %w", err)
}
return forced, nil
}
// enforceForcedMCPServerIDs appends owner-readable Force On config IDs
// missing from ids so callers cannot exclude such servers by stripping
// IDs from a request (Cure53 CDM-02-010).
func enforceForcedMCPServerIDs(ctx context.Context, store database.Store, organizationID, ownerID uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
forced, err := forcedMCPServerConfigsForOwner(ctx, store, organizationID, ownerID)
if err != nil {
// Fail closed: proceeding without the forced set would
// silently bypass a security policy.
return nil, xerrors.Errorf("get forced MCP server configs: %w", err)
return nil, err
}
merged := slices.Clone(ids)
if merged == nil {
@@ -1258,7 +1269,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
// Force On MCP servers are enforced server-side so a caller
// cannot exclude them by stripping IDs from the request
// (Cure53 CDM-02-010).
enforcedMCPServerIDs, err := enforceForcedMCPServerIDs(ctx, p.db, opts.OrganizationID, opts.MCPServerIDs)
enforcedMCPServerIDs, err := enforceForcedMCPServerIDs(ctx, p.db, opts.OrganizationID, opts.OwnerID, opts.MCPServerIDs)
if err != nil {
return database.Chat{}, err
}
@@ -1516,7 +1527,7 @@ func (p *Server) SendMessage(
// Force On MCP servers are enforced server-side so a
// caller cannot remove them by tampering with the
// update (Cure53 CDM-02-010).
enforcedIDs, enforceErr := enforceForcedMCPServerIDs(ctx, store, lockedChat.OrganizationID, *requestedMCPServerIDs)
enforcedIDs, enforceErr := enforceForcedMCPServerIDs(ctx, store, lockedChat.OrganizationID, lockedChat.OwnerID, *requestedMCPServerIDs)
if enforceErr != nil {
return enforceErr
}
+43 -2
View File
@@ -12,11 +12,18 @@ import (
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
@@ -89,14 +96,46 @@ func TestCreateChat_ForceOnMCPServerEnforced(t *testing.T) {
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
// Grant read access only to an unrelated group.
dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
OrganizationID: org.ID,
DisplayName: "ACL Denied Forced MCP",
Slug: "acl-denied-forced-mcp",
Url: newEchoMCPTestServer(t, "acl-denied-forced-mcp"),
Availability: "force_on",
GroupACL: database.ChatACL{
uuid.NewString(): {Permissions: []policy.Action{policy.ActionRead}},
},
UserACL: database.ChatACL{},
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
// Use the dbauthz wrapper so the MCP ACL post-filter runs.
authzDB := dbauthz.New(
db,
rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()),
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
coderdtest.AccessControlStorePointer(),
)
server := newActiveTestServer(t, authzDB, ps, func(cfg *chatd.Config) {
withoutMCPToolSearch(cfg)
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL))
})
// The seeded member needs agents-access to create a chat.
_, err := db.UpdateMemberRoles(dbauthz.AsSystemRestricted(ctx), database.UpdateMemberRolesParams{
GrantedRoles: []string{rbac.RoleAgentsAccess()},
UserID: user.ID,
OrgID: org.ID,
})
require.NoError(t, err)
ownerSubject, _, err := httpmw.UserRBACSubject(dbauthz.AsSystemRestricted(ctx), db, user.ID, rbac.ScopeAll)
require.NoError(t, err)
ownerCtx := dbauthz.As(ctx, ownerSubject)
// The attacker strips every MCP server ID from the request.
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
chat, err := server.CreateChat(ownerCtx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "forced-mcp-create",
@@ -127,6 +166,8 @@ func TestCreateChat_ForceOnMCPServerEnforced(t *testing.T) {
require.NotEmpty(t, calls)
require.Contains(t, calls[0], "forced-mcp__echo",
"force_on MCP tools must be offered to the LLM despite a stripped mcp_server_ids list")
require.NotContains(t, calls[0], "acl-denied-forced-mcp__echo",
"force_on MCP tools must not be offered when the server's ACL denies the chat owner")
}
// TestSendMessage_ForceOnMCPServerEnforced reproduces CDM-02-010 for
+9 -11
View File
@@ -25,15 +25,10 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// effectiveMCPServerConfigs loads the MCP server configs for a turn:
// the chat's stored selection plus every enabled Force On config.
// Force On inclusion is enforced at generation time, not just at
// write time, so chats persisted before enforcement existed (or
// before an admin marked a server Force On) cannot dodge the policy
// (Cure53 CDM-02-010). Explore chats are exempt: their spawn-time
// snapshot is immutable by design and must never widen after spawn;
// Force On servers reach the snapshot through the parent chat's
// enforced ID list.
// effectiveMCPServerConfigs loads the chat's stored selection plus
// owner-readable Force On configs at generation time, so stored lists
// predating enforcement cannot dodge the policy (Cure53 CDM-02-010).
// Explore chats keep their immutable spawn-time snapshot instead.
func (server *Server) effectiveMCPServerConfigs(
ctx context.Context,
logger slog.Logger,
@@ -54,11 +49,11 @@ func (server *Server) effectiveMCPServerConfigs(
if isExploreSubagentMode(chat.Mode) {
return configs, nil
}
forced, err := server.db.GetForcedMCPServerConfigsByOrganization(ctx, chat.OrganizationID)
forced, err := forcedMCPServerConfigsForOwner(ctx, server.db, chat.OrganizationID, chat.OwnerID)
if err != nil {
// Fail closed: running the turn without the forced set would
// silently bypass a security policy.
return nil, xerrors.Errorf("get forced MCP server configs: %w", err)
return nil, err
}
seen := make(map[uuid.UUID]struct{}, len(configs))
for _, cfg := range configs {
@@ -911,6 +906,9 @@ func latestAssistantText(messages []database.ChatMessage) string {
return ""
}
// ACLs are deliberately not re-checked: revocation blocks new selection but
// leaves already-selected servers usable, like template ACLs for running
// workspaces. Disabling or deleting the config cuts off existing chats.
func enabledMCPServerConfigsForChatOrg(
ctx context.Context,
db database.Store,