Files
coder/coderd/database/queries/mcpserverconfigs.sql
T
Michael Suchacz a3eb1ee930 fix: gate MCP user tokens on owner-scoped personal RBAC (#27945)
Re-gates every `mcp_server_user_tokens` dbauthz method from
`rbac.ResourceDeploymentConfig` onto owner-scoped personal actions,
mirroring the `external_auth_links` prior art. These are per-user
credential rows, not deployment config, and the old gate forced every
call site to elevate with `dbauthz.AsSystemRestricted`.

## Summary

- Reads gate on `ActionReadPersonal` and writes on
`ActionUpdatePersonal` against the token owner
(`MCPServerUserToken.RBACObject()` derives from `user_id`). The id-keyed
refresh and refresh-failure mutations resolve the owner via a
`GetMCPServerUserTokenByID` prefetch inside dbauthz; the mutation
queries cannot retarget a row's owner, so there is no fetch-authorize
race.
- All seven token-path `AsSystemRestricted` elevations in
`coderd/mcp.go` and all four in chatd are removed along with their
`//nolint:gocritic` markers; handlers use the request actor.
- The daemon-wide chatd subject keeps only `ActionReadPersonal` on
`ResourceUser`. Background token refresh and failure persistence use a
new per-user `AsChatdTokenOwner` subject holding only user-level
`ActionUpdatePersonal` scoped to the token owner (both mutations resolve
the row via the underlying store, so the subject needs no read grant),
and chatd never holds site-wide personal-write access. `TestAsChatd`
pins the complete `ResourceUser` action set and
`TestMCPServerUserTokensAuth` exercises both subjects against the real
authorizer.
- New strict-authorizer test (`rbac.NewAuthorizer` against real rows):
owner passes get/upsert/delete, a stranger fails, and an org admin of
another org fails.

No user-visible behavior change. Stacked on #27944. Completes the MCP
org-separation stack.

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

UAT: regression pass on a dogfood instance: OAuth2 connect redirect,
member list/status with redaction, self-scoped disconnect (cannot touch
another user's token), end-to-end chat MCP tool invocation, and
lower-stack audit/ACL smoke checks.

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

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
2026-08-19 19:36:59 +00:00

319 lines
8.0 KiB
SQL

-- name: GetMCPServerConfigByID :one
SELECT
*
FROM
mcp_server_configs
WHERE
id = @id::uuid;
-- name: GetMCPServerConfigByIDForUpdate :one
SELECT
*
FROM
mcp_server_configs
WHERE
id = @id::uuid
FOR UPDATE;
-- name: GetMCPServerConfigByOrganizationAndSlug :one
SELECT
*
FROM
mcp_server_configs
WHERE
organization_id = @organization_id::uuid
AND slug = @slug::text;
-- name: GetMCPServerConfigsByOrganization :many
SELECT
*
FROM
mcp_server_configs
WHERE
organization_id = @organization_id::uuid
-- Authorize Filter clause will be injected below in GetAuthorizedMCPServerConfigs
-- @authorize_filter
ORDER BY
display_name ASC;
-- name: GetEnabledMCPServerConfigsByOrganization :many
SELECT
*
FROM
mcp_server_configs
WHERE
organization_id = @organization_id::uuid
AND enabled = TRUE
ORDER BY
display_name ASC;
-- name: GetEnabledMCPServerConfigsByOrganizationAndIDs :many
SELECT
*
FROM
mcp_server_configs
WHERE
organization_id = @organization_id::uuid
AND id = ANY(@ids::uuid[])
AND enabled = TRUE
ORDER BY
display_name ASC;
-- name: GetForcedMCPServerConfigsByOrganization :many
SELECT
*
FROM
mcp_server_configs
WHERE
organization_id = @organization_id::uuid
AND enabled = TRUE
AND availability = 'force_on'
ORDER BY
display_name ASC;
-- name: InsertMCPServerConfig :one
INSERT INTO mcp_server_configs (
id,
organization_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_revocation_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,
model_intent,
allow_in_plan_mode,
forward_coder_headers,
group_acl,
user_acl,
created_by,
updated_by
) VALUES (
@id::uuid,
@organization_id::uuid,
@display_name::text,
@slug::text,
@description::text,
@icon_url::text,
@transport::text,
@url::text,
@auth_type::text,
@oauth2_client_id::text,
@oauth2_client_secret::text,
sqlc.narg('oauth2_client_secret_key_id')::text,
@oauth2_auth_url::text,
@oauth2_token_url::text,
@oauth2_revocation_url::text,
@oauth2_scopes::text,
@api_key_header::text,
@api_key_value::text,
sqlc.narg('api_key_value_key_id')::text,
@custom_headers::text,
sqlc.narg('custom_headers_key_id')::text,
@tool_allow_list::text[],
@tool_deny_list::text[],
@availability::text,
@enabled::boolean,
@model_intent::boolean,
@allow_in_plan_mode::boolean,
@forward_coder_headers::boolean,
@group_acl,
@user_acl,
@created_by::uuid,
@updated_by::uuid
)
RETURNING
*;
-- name: UpdateMCPServerConfig :one
UPDATE
mcp_server_configs
SET
display_name = @display_name::text,
slug = @slug::text,
description = @description::text,
icon_url = @icon_url::text,
transport = @transport::text,
url = @url::text,
auth_type = @auth_type::text,
oauth2_client_id = @oauth2_client_id::text,
oauth2_client_secret = @oauth2_client_secret::text,
oauth2_client_secret_key_id = sqlc.narg('oauth2_client_secret_key_id')::text,
oauth2_auth_url = @oauth2_auth_url::text,
oauth2_token_url = @oauth2_token_url::text,
oauth2_revocation_url = @oauth2_revocation_url::text,
oauth2_scopes = @oauth2_scopes::text,
api_key_header = @api_key_header::text,
api_key_value = @api_key_value::text,
api_key_value_key_id = sqlc.narg('api_key_value_key_id')::text,
custom_headers = @custom_headers::text,
custom_headers_key_id = sqlc.narg('custom_headers_key_id')::text,
tool_allow_list = @tool_allow_list::text[],
tool_deny_list = @tool_deny_list::text[],
availability = @availability::text,
enabled = @enabled::boolean,
model_intent = @model_intent::boolean,
allow_in_plan_mode = @allow_in_plan_mode::boolean,
forward_coder_headers = @forward_coder_headers::boolean,
updated_by = @updated_by::uuid,
updated_at = NOW()
WHERE
id = @id::uuid
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
WHERE
id = @id::uuid;
-- name: GetMCPServerUserToken :one
SELECT
*
FROM
mcp_server_user_tokens
WHERE
mcp_server_config_id = @mcp_server_config_id::uuid
AND user_id = @user_id::uuid;
-- name: GetMCPServerUserTokenByID :one
SELECT
*
FROM
mcp_server_user_tokens
WHERE
id = @id::uuid;
-- name: GetMCPServerUserTokensByUserID :many
SELECT
*
FROM
mcp_server_user_tokens
WHERE
user_id = @user_id::uuid;
-- name: UpsertMCPServerUserToken :one
INSERT INTO mcp_server_user_tokens (
mcp_server_config_id,
user_id,
access_token,
access_token_key_id,
refresh_token,
refresh_token_key_id,
token_type,
expiry
) VALUES (
@mcp_server_config_id::uuid,
@user_id::uuid,
@access_token::text,
sqlc.narg('access_token_key_id')::text,
@refresh_token::text,
sqlc.narg('refresh_token_key_id')::text,
@token_type::text,
sqlc.narg('expiry')::timestamptz
)
ON CONFLICT (mcp_server_config_id, user_id) DO UPDATE SET
access_token = @access_token::text,
access_token_key_id = sqlc.narg('access_token_key_id')::text,
refresh_token = @refresh_token::text,
refresh_token_key_id = sqlc.narg('refresh_token_key_id')::text,
token_type = @token_type::text,
expiry = sqlc.narg('expiry')::timestamptz,
-- New token material means the user re-authenticated, so any
-- cached permanent refresh failure no longer applies.
oauth_refresh_failure_reason = '',
updated_at = NOW()
RETURNING
*;
-- name: UpdateMCPServerUserTokenFromRefresh :one
-- Refresh persistence must not recreate a token deleted by disconnect.
-- The optimistic lock also prevents stale refreshes from replacing newer tokens.
UPDATE mcp_server_user_tokens
SET
access_token = @access_token::text,
access_token_key_id = sqlc.narg('access_token_key_id')::text,
refresh_token = @refresh_token::text,
refresh_token_key_id = sqlc.narg('refresh_token_key_id')::text,
token_type = @token_type::text,
expiry = sqlc.narg('expiry')::timestamptz,
oauth_refresh_failure_reason = '',
updated_at = NOW()
WHERE
id = @id::uuid
AND updated_at = @updated_at::timestamptz
RETURNING
*;
-- name: MarkMCPServerUserTokenRefreshFailure :one
-- Records a permanent refresh failure (e.g. revoked grant) and clears
-- the dead token material so it is never attached to a request again.
-- The updated_at predicate provides optimistic concurrency: if another
-- request refreshed or replaced the token since it was read, this
-- update matches zero rows and returns sql.ErrNoRows.
UPDATE mcp_server_user_tokens
SET
access_token = '',
access_token_key_id = NULL,
refresh_token = '',
refresh_token_key_id = NULL,
expiry = NULL,
oauth_refresh_failure_reason = @oauth_refresh_failure_reason::text,
updated_at = NOW()
WHERE
id = @id::uuid
AND updated_at = @updated_at::timestamptz
RETURNING
*;
-- name: DeleteMCPServerUserToken :exec
DELETE FROM
mcp_server_user_tokens
WHERE
mcp_server_config_id = @mcp_server_config_id::uuid
AND user_id = @user_id::uuid;
-- name: DeleteMCPServerUserTokensByConfigID :exec
DELETE FROM
mcp_server_user_tokens
WHERE
mcp_server_config_id = @mcp_server_config_id::uuid;
-- name: CleanupDeletedMCPServerIDsFromChats :exec
UPDATE chats
SET mcp_server_ids = (
SELECT COALESCE(array_agg(sid), '{}')
FROM unnest(chats.mcp_server_ids) AS sid
WHERE sid IN (SELECT id FROM mcp_server_configs)
)
WHERE mcp_server_ids != '{}'
AND NOT (mcp_server_ids <@ COALESCE((SELECT array_agg(id) FROM mcp_server_configs), '{}'));