From 43e36222ec38a9471a268012a6a12ba5ae6b4391 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:52:46 +0000 Subject: [PATCH 01/11] fix: gate MCP user token queries on owner personal RBAC actions mcp_server_user_tokens rows are per-user credentials, but every dbauthz method was gated on ResourceDeploymentConfig, forcing all call sites to elevate with AsSystemRestricted. Re-gate them on ActionReadPersonal and ActionUpdatePersonal against the owning user, mirroring the external_auth_links pattern, and drop the token-path elevations in coderd/mcp.go and chatd. The id-keyed refresh mutations resolve the owner by prefetching the row via a new GetMCPServerUserTokenByID query. The chatd subject gains ActionUpdatePersonal so background token refresh and failure persistence pass without elevation. --- coderd/database/dbauthz/dbauthz.go | 46 ++++++++++---------- coderd/database/dbauthz/dbauthz_test.go | 33 ++++++++++---- coderd/database/dbmetrics/querymetrics.go | 8 ++++ coderd/database/dbmock/dbmock.go | 15 +++++++ coderd/database/modelmethods.go | 6 +++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 29 ++++++++++++ coderd/database/queries/mcpserverconfigs.sql | 8 ++++ coderd/mcp.go | 35 ++++++--------- coderd/x/chatd/chatd.go | 15 ++----- enterprise/dbcrypt/dbcrypt.go | 11 +++++ enterprise/dbcrypt/dbcrypt_internal_test.go | 19 ++++++++ 12 files changed, 161 insertions(+), 65 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 31906ab90ae..9d7926043db 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -795,7 +795,9 @@ var ( rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, rbac.ResourceMCPServerConfig.Type: {policy.ActionRead}, - rbac.ResourceUser.Type: {policy.ActionReadPersonal}, + // UpdatePersonal covers persisting refreshed MCP OAuth2 + // tokens and permanent refresh failures for chat owners. + rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -2295,10 +2297,9 @@ func (q *querier) DeleteMCPServerConfigByID(ctx context.Context, id uuid.UUID) e } func (q *querier) DeleteMCPServerUserToken(ctx context.Context, arg database.DeleteMCPServerUserTokenParams) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.DeleteMCPServerUserToken(ctx, arg) + return fetchAndExec(q.log, q.auth, policy.ActionUpdatePersonal, func(ctx context.Context, arg database.DeleteMCPServerUserTokenParams) (database.MCPServerUserToken, error) { + return q.db.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams(arg)) + }, q.db.DeleteMCPServerUserToken)(ctx, arg) } func (q *querier) DeleteMCPServerUserTokensByConfigID(ctx context.Context, mcpServerConfigID uuid.UUID) error { @@ -4082,17 +4083,15 @@ func (q *querier) GetMCPServerConfigsByOrganization(ctx context.Context, organiz } func (q *querier) GetMCPServerUserToken(ctx context.Context, arg database.GetMCPServerUserTokenParams) (database.MCPServerUserToken, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return database.MCPServerUserToken{}, err - } - return q.db.GetMCPServerUserToken(ctx, arg) + return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetMCPServerUserToken)(ctx, arg) +} + +func (q *querier) GetMCPServerUserTokenByID(ctx context.Context, id uuid.UUID) (database.MCPServerUserToken, error) { + return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetMCPServerUserTokenByID)(ctx, id) } func (q *querier) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]database.MCPServerUserToken, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return nil, err - } - return q.db.GetMCPServerUserTokensByUserID(ctx, userID) + return fetchWithPostFilter(q.auth, policy.ActionReadPersonal, q.db.GetMCPServerUserTokensByUserID)(ctx, userID) } func (q *querier) GetNextPendingWorkspaceBuildOrchestrationForUpdate(ctx context.Context) (database.WorkspaceBuildOrchestration, error) { @@ -6990,10 +6989,12 @@ func (q *querier) MarkChatsContextDirtyByAgent(ctx context.Context, arg database } func (q *querier) MarkMCPServerUserTokenRefreshFailure(ctx context.Context, arg database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return database.MCPServerUserToken{}, err + // The params carry only the token id, so resolve the owner from + // the row itself to authorize the personal update. + fetch := func(ctx context.Context, arg database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { + return q.db.GetMCPServerUserTokenByID(ctx, arg.ID) } - return q.db.MarkMCPServerUserTokenRefreshFailure(ctx, arg) + return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.MarkMCPServerUserTokenRefreshFailure)(ctx, arg) } func (q *querier) OIDCClaimFieldValues(ctx context.Context, args database.OIDCClaimFieldValuesParams) ([]string, error) { @@ -7685,10 +7686,12 @@ func (q *querier) UpdateMCPServerConfigACLByID(ctx context.Context, arg database } 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 + // The params carry only the token id, so resolve the owner from + // the row itself to authorize the personal update. + fetch := func(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) { + return q.db.GetMCPServerUserTokenByID(ctx, arg.ID) } - return q.db.UpdateMCPServerUserTokenFromRefresh(ctx, arg) + return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateMCPServerUserTokenFromRefresh)(ctx, arg) } func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) { @@ -9040,10 +9043,7 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error { } func (q *querier) UpsertMCPServerUserToken(ctx context.Context, arg database.UpsertMCPServerUserTokenParams) (database.MCPServerUserToken, error) { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return database.MCPServerUserToken{}, err - } - return q.db.UpsertMCPServerUserToken(ctx, arg) + return insertWithAction(q.log, q.auth, rbac.ResourceUserObject(arg.UserID), policy.ActionUpdatePersonal, q.db.UpsertMCPServerUserToken)(ctx, arg) } func (q *querier) UpsertNotificationReportGeneratorLog(ctx context.Context, arg database.UpsertNotificationReportGeneratorLogParams) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 415e9264e4f..111b3dbcb1a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1638,13 +1638,15 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().DeleteMCPServerConfigByID(gomock.Any(), config.ID).Return(nil).AnyTimes() check.Args(config.ID).Asserts(config, policy.ActionDelete) })) - s.Run("DeleteMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + s.Run("DeleteMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.DeleteMCPServerUserTokenParams{ MCPServerConfigID: uuid.New(), UserID: uuid.New(), } + token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{MCPServerConfigID: arg.MCPServerConfigID, UserID: arg.UserID}) + dbm.EXPECT().GetMCPServerUserToken(gomock.Any(), database.GetMCPServerUserTokenParams(arg)).Return(token, nil).AnyTimes() dbm.EXPECT().DeleteMCPServerUserToken(gomock.Any(), arg).Return(nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + check.Args(arg).Asserts(token, policy.ActionUpdatePersonal) })) s.Run("DeleteMCPServerUserTokensByConfigID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.MCPServerConfig{}) @@ -1716,13 +1718,19 @@ func (s *MethodTestSuite) TestChats() { } token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{MCPServerConfigID: arg.MCPServerConfigID, UserID: arg.UserID}) dbm.EXPECT().GetMCPServerUserToken(gomock.Any(), arg).Return(token, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(token) + check.Args(arg).Asserts(token, policy.ActionReadPersonal).Returns(token) + })) + s.Run("GetMCPServerUserTokenByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{}) + dbm.EXPECT().GetMCPServerUserTokenByID(gomock.Any(), token.ID).Return(token, nil).AnyTimes() + check.Args(token.ID).Asserts(token, policy.ActionReadPersonal).Returns(token) })) s.Run("GetMCPServerUserTokensByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { userID := uuid.New() - tokens := []database.MCPServerUserToken{testutil.Fake(s.T(), faker, database.MCPServerUserToken{UserID: userID})} - dbm.EXPECT().GetMCPServerUserTokensByUserID(gomock.Any(), userID).Return(tokens, nil).AnyTimes() - check.Args(userID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(tokens) + tokenA := testutil.Fake(s.T(), faker, database.MCPServerUserToken{UserID: userID}) + tokenB := testutil.Fake(s.T(), faker, database.MCPServerUserToken{UserID: userID}) + dbm.EXPECT().GetMCPServerUserTokensByUserID(gomock.Any(), userID).Return([]database.MCPServerUserToken{tokenA, tokenB}, nil).AnyTimes() + check.Args(userID).Asserts(tokenA, policy.ActionReadPersonal, tokenB, policy.ActionReadPersonal).OutOfOrder().Returns([]database.MCPServerUserToken{tokenA, tokenB}) })) s.Run("InsertMCPServerConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.InsertMCPServerConfigParams{ @@ -1803,8 +1811,9 @@ func (s *MethodTestSuite) TestChats() { AccessToken: "refreshed-access-token", TokenType: "bearer", } + dbm.EXPECT().GetMCPServerUserTokenByID(gomock.Any(), token.ID).Return(token, nil).AnyTimes() dbm.EXPECT().UpdateMCPServerUserTokenFromRefresh(gomock.Any(), arg).Return(token, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(token) + check.Args(arg).Asserts(token, policy.ActionUpdatePersonal).Returns(token) })) s.Run("UpsertMCPServerUserToken", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.UpsertMCPServerUserTokenParams{ @@ -1815,7 +1824,7 @@ func (s *MethodTestSuite) TestChats() { } token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{MCPServerConfigID: arg.MCPServerConfigID, UserID: arg.UserID}) dbm.EXPECT().UpsertMCPServerUserToken(gomock.Any(), arg).Return(token, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(token) + check.Args(arg).Asserts(rbac.ResourceUserObject(arg.UserID), policy.ActionUpdatePersonal).Returns(token) })) s.Run("MarkMCPServerUserTokenRefreshFailure", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { token := testutil.Fake(s.T(), faker, database.MCPServerUserToken{}) @@ -1824,8 +1833,9 @@ func (s *MethodTestSuite) TestChats() { UpdatedAt: token.UpdatedAt, OauthRefreshFailureReason: "invalid_grant", } + dbm.EXPECT().GetMCPServerUserTokenByID(gomock.Any(), token.ID).Return(token, nil).AnyTimes() dbm.EXPECT().MarkMCPServerUserTokenRefreshFailure(gomock.Any(), arg).Return(token, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(token) + check.Args(arg).Asserts(token, policy.ActionUpdatePersonal).Returns(token) })) } @@ -7605,6 +7615,11 @@ func TestAsChatd(t *testing.T) { // User read_personal (needed for GetUserChatCustomPrompt). err = auth.Authorize(ctx, actor, policy.ActionReadPersonal, rbac.ResourceUser) require.NoError(t, err, "user read_personal should be allowed") + + // User update_personal (needed to persist refreshed MCP OAuth2 + // tokens and permanent refresh failures for chat owners). + err = auth.Authorize(ctx, actor, policy.ActionUpdatePersonal, rbac.ResourceUser) + require.NoError(t, err, "user update_personal should be allowed") }) t.Run("DeniedActions", func(t *testing.T) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d621d792f3d..ae63a286768 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2369,6 +2369,14 @@ func (m queryMetricsStore) GetMCPServerUserToken(ctx context.Context, arg databa return r0, r1 } +func (m queryMetricsStore) GetMCPServerUserTokenByID(ctx context.Context, id uuid.UUID) (database.MCPServerUserToken, error) { + start := time.Now() + r0, r1 := m.s.GetMCPServerUserTokenByID(ctx, id) + m.queryLatencies.WithLabelValues("GetMCPServerUserTokenByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetMCPServerUserTokenByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]database.MCPServerUserToken, error) { start := time.Now() r0, r1 := m.s.GetMCPServerUserTokensByUserID(ctx, userID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 34051146fb3..6864efcb86e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4424,6 +4424,21 @@ func (mr *MockStoreMockRecorder) GetMCPServerUserToken(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerUserToken", reflect.TypeOf((*MockStore)(nil).GetMCPServerUserToken), ctx, arg) } +// GetMCPServerUserTokenByID mocks base method. +func (m *MockStore) GetMCPServerUserTokenByID(ctx context.Context, id uuid.UUID) (database.MCPServerUserToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMCPServerUserTokenByID", ctx, id) + ret0, _ := ret[0].(database.MCPServerUserToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMCPServerUserTokenByID indicates an expected call of GetMCPServerUserTokenByID. +func (mr *MockStoreMockRecorder) GetMCPServerUserTokenByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMCPServerUserTokenByID", reflect.TypeOf((*MockStore)(nil).GetMCPServerUserTokenByID), ctx, id) +} + // GetMCPServerUserTokensByUserID mocks base method. func (m *MockStore) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]database.MCPServerUserToken, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 58c18a33653..efc6f9a19c2 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -235,6 +235,12 @@ func (m MCPServerConfig) RBACObject() rbac.Object { WithACLUserList(m.UserACL.RBACACL()) } +// MCP OAuth2 tokens are per-user credentials, so access is governed +// by personal actions on the owning user, not by the config's RBAC. +func (t MCPServerUserToken) RBACObject() rbac.Object { + return rbac.ResourceUserObject(t.UserID) +} + func (c Chat) IsSubChat() bool { return c.RootChatID.Valid || c.ParentChatID.Valid } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7733a397e8d..58c197125b5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -651,6 +651,7 @@ type sqlcQuerier interface { GetMCPServerConfigByOrganizationAndSlug(ctx context.Context, arg GetMCPServerConfigByOrganizationAndSlugParams) (MCPServerConfig, error) GetMCPServerConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]MCPServerConfig, error) GetMCPServerUserToken(ctx context.Context, arg GetMCPServerUserTokenParams) (MCPServerUserToken, error) + GetMCPServerUserTokenByID(ctx context.Context, id uuid.UUID) (MCPServerUserToken, error) GetMCPServerUserTokensByUserID(ctx context.Context, userID uuid.UUID) ([]MCPServerUserToken, error) // Must be called from within a transaction. The row lock is released // when the transaction ends. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dcf1305c10a..58492959776 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17628,6 +17628,35 @@ func (q *sqlQuerier) GetMCPServerUserToken(ctx context.Context, arg GetMCPServer return i, err } +const getMCPServerUserTokenByID = `-- name: GetMCPServerUserTokenByID :one +SELECT + id, mcp_server_config_id, user_id, access_token, access_token_key_id, refresh_token, refresh_token_key_id, token_type, expiry, created_at, updated_at, oauth_refresh_failure_reason +FROM + mcp_server_user_tokens +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) GetMCPServerUserTokenByID(ctx context.Context, id uuid.UUID) (MCPServerUserToken, error) { + row := q.db.QueryRowContext(ctx, getMCPServerUserTokenByID, id) + var i MCPServerUserToken + err := row.Scan( + &i.ID, + &i.MCPServerConfigID, + &i.UserID, + &i.AccessToken, + &i.AccessTokenKeyID, + &i.RefreshToken, + &i.RefreshTokenKeyID, + &i.TokenType, + &i.Expiry, + &i.CreatedAt, + &i.UpdatedAt, + &i.OauthRefreshFailureReason, + ) + return i, err +} + const getMCPServerUserTokensByUserID = `-- name: GetMCPServerUserTokensByUserID :many SELECT id, mcp_server_config_id, user_id, access_token, access_token_key_id, refresh_token, refresh_token_key_id, token_type, expiry, created_at, updated_at, oauth_refresh_failure_reason diff --git a/coderd/database/queries/mcpserverconfigs.sql b/coderd/database/queries/mcpserverconfigs.sql index efa944fe349..60c98333e91 100644 --- a/coderd/database/queries/mcpserverconfigs.sql +++ b/coderd/database/queries/mcpserverconfigs.sql @@ -204,6 +204,14 @@ 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 * diff --git a/coderd/mcp.go b/coderd/mcp.go index 03d16fab6a3..a9ba1619255 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -64,9 +64,9 @@ func newOIDCMCPTokenSource(db database.Store, config promoauth.OAuth2Config, log // OIDCAccessToken implements mcpclient.UserOIDCTokenSource. It // refreshes expired tokens and persists the refreshed token back // to user_links. The chatd dbauthz subject does not grant -// ResourceSystem.Read or ResourceUser.UpdatePersonal, so DB calls -// elevate to AsSystemRestricted; the per-user authorization is -// already enforced by the API handler that owns ctx. +// ResourceSystem.Read, so the user_links calls elevate to +// AsSystemRestricted; the per-user authorization is already +// enforced by the API handler that owns ctx. func (s *oidcMCPTokenSource) OIDCAccessToken(ctx context.Context, userID uuid.UUID) (string, error) { //nolint:gocritic // user_links read needs system access; the // caller's user identity is supplied via the userID parameter. @@ -222,8 +222,7 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { // Look up the calling user's OAuth2 tokens so we can populate // auth_connected per server. Attempt to refresh expired tokens // so the status is accurate and the token is ready for use. - //nolint:gocritic // Token authorization is handled separately from config RBAC. - userTokens, err := api.Database.GetMCPServerUserTokensByUserID(dbauthz.AsSystemRestricted(ctx), apiKey.UserID) + userTokens, err := api.Database.GetMCPServerUserTokensByUserID(ctx, apiKey.UserID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get user tokens.", @@ -552,8 +551,7 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { // Populate AuthConnected for the calling user. Attempt to // refresh the token so the status is accurate. if config.AuthType == "oauth2" { - //nolint:gocritic // Token authorization is handled separately from config RBAC. - tok, err := api.Database.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + tok, err := api.Database.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: config.ID, UserID: apiKey.UserID, }) @@ -1273,8 +1271,7 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) current.OAuth2ClientID != config.OAuth2ClientID { return errMCPConfigSupersededDuringAuth } - //nolint:gocritic // Users store their own tokens. - _, err = tx.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ + _, err = tx.UpsertMCPServerUserToken(ctx, database.UpsertMCPServerUserTokenParams{ MCPServerConfigID: config.ID, UserID: apiKey.UserID, AccessToken: token.AccessToken, @@ -1336,15 +1333,13 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques return } - //nolint:gocritic // Users manage their own tokens. - systemCtx := dbauthz.AsSystemRestricted(ctx) var ( config database.MCPServerConfig token database.MCPServerUserToken ) // Serializable isolation keeps the revoked token aligned with the row deleted locally. err := api.Database.InTx(func(tx database.Store) error { - dbToken, err := tx.GetMCPServerUserToken(systemCtx, database.GetMCPServerUserTokenParams{ + dbToken, err := tx.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: configID, UserID: apiKey.UserID, }) @@ -1356,11 +1351,12 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques // system context keeps disconnect available to token owners // who can no longer read the config, such as users removed // from the organization. - dbConfig, err := tx.GetMCPServerConfigByID(systemCtx, configID) + //nolint:gocritic // Token owners keep disconnect access without config read. + dbConfig, err := tx.GetMCPServerConfigByID(dbauthz.AsSystemRestricted(ctx), configID) if err != nil { return err } - if err := tx.DeleteMCPServerUserToken(systemCtx, database.DeleteMCPServerUserTokenParams{ + if err := tx.DeleteMCPServerUserToken(ctx, database.DeleteMCPServerUserTokenParams{ MCPServerConfigID: configID, UserID: apiKey.UserID, }); err != nil { @@ -1447,10 +1443,8 @@ func (api *API) refreshMCPUserToken( expiry = sql.NullTime{Time: result.Expiry, Valid: true} } - //nolint:gocritic // Need system-level write access to - // persist the refreshed OAuth2 token. _, err = api.Database.UpdateMCPServerUserTokenFromRefresh( - dbauthz.AsSystemRestricted(ctx), + ctx, database.UpdateMCPServerUserTokenFromRefreshParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -1484,9 +1478,8 @@ func (api *API) currentMCPUserTokenConnected( ctx context.Context, tok database.MCPServerUserToken, ) (bool, error) { - //nolint:gocritic // Reading the current token requires system access. current, err := api.Database.GetMCPServerUserToken( - dbauthz.AsSystemRestricted(ctx), + ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: tok.MCPServerConfigID, UserID: tok.UserID, @@ -1513,10 +1506,8 @@ func (api *API) markMCPTokenRefreshFailure( tok database.MCPServerUserToken, refreshErr error, ) bool { - //nolint:gocritic // Need system-level write access to persist - // the refresh failure. _, err := api.Database.MarkMCPServerUserTokenRefreshFailure( - dbauthz.AsSystemRestricted(ctx), + ctx, database.MarkMCPServerUserTokenRefreshFailureParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b54a273c3ba..97284ca1d67 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5238,10 +5238,8 @@ func (p *Server) refreshMCPTokenIfNeeded( expiry = sql.NullTime{Time: result.Expiry, Valid: true} } - //nolint:gocritic // Chatd needs system-level write access to - // persist the refreshed OAuth2 token for the user. updated, err := p.db.UpdateMCPServerUserTokenFromRefresh( - dbauthz.AsSystemRestricted(ctx), + ctx, database.UpdateMCPServerUserTokenFromRefreshParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -5256,9 +5254,8 @@ func (p *Server) refreshMCPTokenIfNeeded( if err != nil { if xerrors.Is(err, sql.ErrNoRows) { // A disconnect or re-authentication can win the optimistic update. - //nolint:gocritic // Reading the winning token requires system access. current, readErr := p.db.GetMCPServerUserToken( - dbauthz.AsSystemRestricted(ctx), + ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: tok.MCPServerConfigID, UserID: tok.UserID, @@ -5315,10 +5312,8 @@ func (p *Server) markMCPTokenRefreshFailure( slog.Error(refreshErr), ) - //nolint:gocritic // Chatd needs system-level write access to - // persist the refresh failure for the user. marked, err := p.db.MarkMCPServerUserTokenRefreshFailure( - dbauthz.AsSystemRestricted(ctx), + ctx, database.MarkMCPServerUserTokenRefreshFailureParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -5333,10 +5328,8 @@ func (p *Server) markMCPTokenRefreshFailure( // Optimistic lock miss: a concurrent request refreshed or // replaced the token after we read it, so our failure is // stale. Use the winner's row instead. - //nolint:gocritic // Chatd needs system-level read access to - // load the concurrently updated token. current, readErr := p.db.GetMCPServerUserToken( - dbauthz.AsSystemRestricted(ctx), + ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: tok.MCPServerConfigID, UserID: tok.UserID, diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 60f3f92d143..72b9003b2e9 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -812,6 +812,17 @@ func (db *dbCrypt) GetMCPServerUserToken(ctx context.Context, arg database.GetMC return tok, nil } +func (db *dbCrypt) GetMCPServerUserTokenByID(ctx context.Context, id uuid.UUID) (database.MCPServerUserToken, error) { + tok, err := db.Store.GetMCPServerUserTokenByID(ctx, id) + if err != nil { + return database.MCPServerUserToken{}, err + } + if err := db.decryptMCPServerUserToken(&tok); err != nil { + return database.MCPServerUserToken{}, err + } + return tok, nil +} + func (db *dbCrypt) MarkMCPServerUserTokenRefreshFailure(ctx context.Context, params database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { // The query clears the encrypted token fields, so nothing needs // encrypting; decrypt the returned row for consistency with the diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index 5ecb4a73246..ea0c01231a9 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -1640,6 +1640,25 @@ func TestMCPServerUserTokens(t *testing.T) { requireEncryptedEquals(t, ciphers[0], rawTok.RefreshToken, refreshToken) }) + t.Run("GetMCPServerUserTokenByID", func(t *testing.T) { + t.Parallel() + db, crypt, ciphers := setup(t) + _, tok := insertConfigAndToken(t, crypt, ciphers) + + got, err := crypt.GetMCPServerUserTokenByID(ctx, tok.ID) + require.NoError(t, err) + require.Equal(t, accessToken, got.AccessToken) + require.Equal(t, refreshToken, got.RefreshToken) + require.Equal(t, ciphers[0].HexDigest(), got.AccessTokenKeyID.String) + require.Equal(t, ciphers[0].HexDigest(), got.RefreshTokenKeyID.String) + + // Raw values must be encrypted. + rawTok, err := db.GetMCPServerUserTokenByID(ctx, tok.ID) + require.NoError(t, err) + requireEncryptedEquals(t, ciphers[0], rawTok.AccessToken, accessToken) + requireEncryptedEquals(t, ciphers[0], rawTok.RefreshToken, refreshToken) + }) + t.Run("GetMCPServerUserTokensByUserID", func(t *testing.T) { t.Parallel() db, crypt, ciphers := setup(t) From de0a6697233d9a50838e36ac552588f6904913ce Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:08:38 +0000 Subject: [PATCH 02/11] chore: comment cleanup --- coderd/database/dbauthz/dbauthz.go | 8 ++++---- coderd/mcp.go | 7 +------ enterprise/dbcrypt/dbcrypt_internal_test.go | 1 - 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9d7926043db..8846c515159 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6989,8 +6989,8 @@ func (q *querier) MarkChatsContextDirtyByAgent(ctx context.Context, arg database } func (q *querier) MarkMCPServerUserTokenRefreshFailure(ctx context.Context, arg database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { - // The params carry only the token id, so resolve the owner from - // the row itself to authorize the personal update. + // The update parameters omit the owner, so load the token before + // authorizing the personal update. fetch := func(ctx context.Context, arg database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { return q.db.GetMCPServerUserTokenByID(ctx, arg.ID) } @@ -7686,8 +7686,8 @@ func (q *querier) UpdateMCPServerConfigACLByID(ctx context.Context, arg database } func (q *querier) UpdateMCPServerUserTokenFromRefresh(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) { - // The params carry only the token id, so resolve the owner from - // the row itself to authorize the personal update. + // The update parameters omit the owner, so load the token before + // authorizing the personal update. fetch := func(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) { return q.db.GetMCPServerUserTokenByID(ctx, arg.ID) } diff --git a/coderd/mcp.go b/coderd/mcp.go index a9ba1619255..099ade5fcba 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -61,12 +61,7 @@ func newOIDCMCPTokenSource(db database.Store, config promoauth.OAuth2Config, log } } -// OIDCAccessToken implements mcpclient.UserOIDCTokenSource. It -// refreshes expired tokens and persists the refreshed token back -// to user_links. The chatd dbauthz subject does not grant -// ResourceSystem.Read, so the user_links calls elevate to -// AsSystemRestricted; the per-user authorization is already -// enforced by the API handler that owns ctx. +// OIDCAccessToken refreshes and persists the user's OIDC token. func (s *oidcMCPTokenSource) OIDCAccessToken(ctx context.Context, userID uuid.UUID) (string, error) { //nolint:gocritic // user_links read needs system access; the // caller's user identity is supplied via the userID parameter. diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index ea0c01231a9..996a384ab6d 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -1652,7 +1652,6 @@ func TestMCPServerUserTokens(t *testing.T) { require.Equal(t, ciphers[0].HexDigest(), got.AccessTokenKeyID.String) require.Equal(t, ciphers[0].HexDigest(), got.RefreshTokenKeyID.String) - // Raw values must be encrypted. rawTok, err := db.GetMCPServerUserTokenByID(ctx, tok.ID) require.NoError(t, err) requireEncryptedEquals(t, ciphers[0], rawTok.AccessToken, accessToken) From 389f08b5836f13f2bc503c43443b07ee612cac0c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:11:38 +0000 Subject: [PATCH 03/11] chore: fold deslop findings into token RBAC polish --- coderd/database/dbauthz/dbauthz.go | 4 ---- coderd/database/modelmethods.go | 13 ++++--------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8846c515159..36a6fc5ff63 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6989,8 +6989,6 @@ func (q *querier) MarkChatsContextDirtyByAgent(ctx context.Context, arg database } func (q *querier) MarkMCPServerUserTokenRefreshFailure(ctx context.Context, arg database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { - // The update parameters omit the owner, so load the token before - // authorizing the personal update. fetch := func(ctx context.Context, arg database.MarkMCPServerUserTokenRefreshFailureParams) (database.MCPServerUserToken, error) { return q.db.GetMCPServerUserTokenByID(ctx, arg.ID) } @@ -7686,8 +7684,6 @@ func (q *querier) UpdateMCPServerConfigACLByID(ctx context.Context, arg database } func (q *querier) UpdateMCPServerUserTokenFromRefresh(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) { - // The update parameters omit the owner, so load the token before - // authorizing the personal update. fetch := func(ctx context.Context, arg database.UpdateMCPServerUserTokenFromRefreshParams) (database.MCPServerUserToken, error) { return q.db.GetMCPServerUserTokenByID(ctx, arg.ID) } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index efc6f9a19c2..d3e0834ccf8 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -235,12 +235,6 @@ func (m MCPServerConfig) RBACObject() rbac.Object { WithACLUserList(m.UserACL.RBACACL()) } -// MCP OAuth2 tokens are per-user credentials, so access is governed -// by personal actions on the owning user, not by the config's RBAC. -func (t MCPServerUserToken) RBACObject() rbac.Object { - return rbac.ResourceUserObject(t.UserID) -} - func (c Chat) IsSubChat() bool { return c.RootChatID.Valid || c.ParentChatID.Valid } @@ -667,9 +661,10 @@ func (u GetUsersRow) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.ID) } -func (u GitSSHKey) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } -func (u ExternalAuthLink) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } -func (u UserLink) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } +func (u GitSSHKey) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } +func (u ExternalAuthLink) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } +func (u UserLink) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } +func (u MCPServerUserToken) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.UserID) } func (u ExternalAuthLink) OAuthToken() *oauth2.Token { return &oauth2.Token{ From 0c8628d7c1dd0d5bbb9ccc1d1fec94563ae89996 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:12:41 +0000 Subject: [PATCH 04/11] test(coderd/database/dbauthz): pin MCP token personal gating with real authorizer The MethodTestSuite entries assert call shape against a fake authorizer, so they cannot prove real policy outcomes. Add a strict-authorizer test over a real token row (owner passes get, upsert, and delete; a stranger and an org admin of another org fail) and pin the chatd subject's complete ResourceUser action set so a future broad grant fails the test. --- coderd/database/dbauthz/dbauthz_test.go | 19 +-- coderd/database/dbauthz/mcptokensauth_test.go | 128 ++++++++++++++++++ 2 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 coderd/database/dbauthz/mcptokensauth_test.go diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 111b3dbcb1a..a2be4ac8cbb 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7612,14 +7612,17 @@ func TestAsChatd(t *testing.T) { err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig) require.Error(t, err, "deployment config update should not be allowed") - // User read_personal (needed for GetUserChatCustomPrompt). - err = auth.Authorize(ctx, actor, policy.ActionReadPersonal, rbac.ResourceUser) - require.NoError(t, err, "user read_personal should be allowed") - - // User update_personal (needed to persist refreshed MCP OAuth2 - // tokens and permanent refresh failures for chat owners). - err = auth.Authorize(ctx, actor, policy.ActionUpdatePersonal, rbac.ResourceUser) - require.NoError(t, err, "user update_personal should be allowed") + // Pin the complete ResourceUser action set: read_personal (user + // chat custom prompts) and update_personal (MCP OAuth2 token + // refresh persistence) only, so a future broad grant fails here. + for _, action := range rbac.ResourceUser.AvailableActions() { + err := auth.Authorize(ctx, actor, action, rbac.ResourceUser) + if action == policy.ActionReadPersonal || action == policy.ActionUpdatePersonal { + require.NoError(t, err, "user %s should be allowed", action) + } else { + require.Error(t, err, "user %s should be denied", action) + } + } }) t.Run("DeniedActions", func(t *testing.T) { diff --git a/coderd/database/dbauthz/mcptokensauth_test.go b/coderd/database/dbauthz/mcptokensauth_test.go new file mode 100644 index 00000000000..6074cff9829 --- /dev/null +++ b/coderd/database/dbauthz/mcptokensauth_test.go @@ -0,0 +1,128 @@ +package dbauthz_test + +import ( + "context" + "testing" + + "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/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" +) + +// TestMCPServerUserTokensAuth exercises the owner-personal gating of +// mcp_server_user_tokens against the real RBAC authorizer, which the +// FakeAuthorizer-based method suite cannot do. +func TestMCPServerUserTokensAuth(t *testing.T) { + t.Parallel() + + authz := rbac.NewAuthorizer(prometheus.NewRegistry()) + store, _ := dbtestutil.NewDB(t) + db := dbauthz.New(store, authz, slogtest.Make(t, &slogtest.Options{ + IgnoreErrors: true, + }), coderdtest.AccessControlStorePointer()) + + org := dbgen.Organization(t, store, database.Organization{}) + otherOrg := dbgen.Organization(t, store, database.Organization{}) + owner := dbgen.User(t, store, database.User{}) + stranger := dbgen.User(t, store, database.User{}) + otherOrgAdmin := dbgen.User(t, store, database.User{}) + + memberSubject := func(userID string) rbac.Subject { + return rbac.Subject{ + ID: userID, + Roles: must(rolestore.Expand( + context.Background(), + store, + []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)}, + )), + Groups: []string{}, + Scope: rbac.ExpandableScope(rbac.ScopeAll), + } + } + + requireAuthorized := func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + } + requireNotAuthorized := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + require.True(t, dbauthz.IsNotAuthorizedError(err), "expected authorization failure, got: %v", err) + } + + testCases := []struct { + Name string + Subject rbac.Subject + Check func(t *testing.T, err error) + }{ + { + Name: "Owner", + Subject: memberSubject(owner.ID.String()), + Check: requireAuthorized, + }, + { + Name: "Stranger", + Subject: memberSubject(stranger.ID.String()), + Check: requireNotAuthorized, + }, + { + Name: "OtherOrgAdmin", + Subject: rbac.Subject{ + ID: otherOrgAdmin.ID.String(), + Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(otherOrg.ID)}.Expand())), + Groups: []string{}, + Scope: rbac.ExpandableScope(rbac.ScopeAll), + }, + Check: requireNotAuthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + // A per-case config keeps parallel subtests from sharing + // token rows; the raw store seeds bypassing authz. + cfg := dbgen.MCPServerConfig(t, store, database.MCPServerConfig{ + OrganizationID: org.ID, + }) + _, err := store.UpsertMCPServerUserToken(context.Background(), database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + AccessToken: "seed-access-token", + TokenType: "bearer", + }) + require.NoError(t, err) + + ctx := dbauthz.As(context.Background(), tc.Subject) + + _, err = db.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + }) + tc.Check(t, err) + + _, err = db.UpsertMCPServerUserToken(ctx, database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + AccessToken: "updated-access-token", + TokenType: "bearer", + }) + tc.Check(t, err) + + err = db.DeleteMCPServerUserToken(ctx, database.DeleteMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + }) + tc.Check(t, err) + }) + } +} From ba6b9c42a89916fecc9bf17eb087c9abbaa96854 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:48:39 +0000 Subject: [PATCH 05/11] fix(coderd): scope chatd MCP token writes to the token owner --- coderd/database/dbauthz/dbauthz.go | 36 +++++++++++++++-- coderd/database/dbauthz/dbauthz_test.go | 7 ++-- coderd/database/dbauthz/mcptokensauth_test.go | 39 +++++++++++++++++++ coderd/rbac/authz.go | 5 ++- coderd/x/chatd/chatd.go | 8 +++- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 36a6fc5ff63..1a9734938ee 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -494,6 +494,29 @@ var ( }.WithCachedASTValue() } + // subjectChatdTokenOwner persists MCP OAuth2 token refreshes and + // refresh failures for one chat owner. User-level permissions keep + // the write scoped to that owner's rows. + subjectChatdTokenOwner = func(userID uuid.UUID) rbac.Subject { + return rbac.Subject{ + Type: rbac.SubjectTypeChatdTokenOwner, + FriendlyName: "Chatd Token Owner", + ID: userID.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "chatdtokenowner"}, + DisplayName: "Chatd Token Owner", + Site: []rbac.Permission{}, + User: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, + }), + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + } + subjectSystemRestricted = rbac.Subject{ Type: rbac.SubjectTypeSystemRestricted, FriendlyName: "System", @@ -795,9 +818,10 @@ var ( rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, rbac.ResourceMCPServerConfig.Type: {policy.ActionRead}, - // UpdatePersonal covers persisting refreshed MCP OAuth2 - // tokens and permanent refresh failures for chat owners. - rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, + // Site-wide UpdatePersonal would let chatd write any + // user's personal data; token writes use the per-user + // AsChatdTokenOwner subject instead. + rbac.ResourceUser.Type: {policy.ActionReadPersonal}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -932,6 +956,12 @@ func AsChatdKeyMinter(ctx context.Context, userID uuid.UUID) context.Context { return As(ctx, subjectChatdKeyMinter(userID)) } +// AsChatdTokenOwner returns a context with an actor that persists MCP +// OAuth2 token refresh results for the specified token owner only. +func AsChatdTokenOwner(ctx context.Context, userID uuid.UUID) context.Context { + return As(ctx, subjectChatdTokenOwner(userID)) +} + // AsSystemRestricted returns a context with an actor that has permissions // required for various system operations (login, logout, metrics cache). // DO NOT USE THIS UNLESS YOU HAVE ABSOLUTELY NO OTHER CHOICE. Prefer using a diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a2be4ac8cbb..fa784ded25b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7613,11 +7613,12 @@ func TestAsChatd(t *testing.T) { require.Error(t, err, "deployment config update should not be allowed") // Pin the complete ResourceUser action set: read_personal (user - // chat custom prompts) and update_personal (MCP OAuth2 token - // refresh persistence) only, so a future broad grant fails here. + // chat custom prompts) only. Token refresh persistence uses the + // per-user AsChatdTokenOwner subject, so a future site-wide + // personal-write grant fails here. for _, action := range rbac.ResourceUser.AvailableActions() { err := auth.Authorize(ctx, actor, action, rbac.ResourceUser) - if action == policy.ActionReadPersonal || action == policy.ActionUpdatePersonal { + if action == policy.ActionReadPersonal { require.NoError(t, err, "user %s should be allowed", action) } else { require.Error(t, err, "user %s should be denied", action) diff --git a/coderd/database/dbauthz/mcptokensauth_test.go b/coderd/database/dbauthz/mcptokensauth_test.go index 6074cff9829..948db2de385 100644 --- a/coderd/database/dbauthz/mcptokensauth_test.go +++ b/coderd/database/dbauthz/mcptokensauth_test.go @@ -85,6 +85,45 @@ func TestMCPServerUserTokensAuth(t *testing.T) { }, } + t.Run("ChatdSubjects", func(t *testing.T) { + t.Parallel() + + cfg := dbgen.MCPServerConfig(t, store, database.MCPServerConfig{ + OrganizationID: org.ID, + }) + token, err := store.UpsertMCPServerUserToken(context.Background(), database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + AccessToken: "chatd-access-token", + TokenType: "bearer", + }) + require.NoError(t, err) + + markFailure := func(ctx context.Context) error { + _, err := db.MarkMCPServerUserTokenRefreshFailure(ctx, database.MarkMCPServerUserTokenRefreshFailureParams{ + ID: token.ID, + UpdatedAt: token.UpdatedAt, + OauthRefreshFailureReason: "test", + }) + return err + } + + // The daemon-wide chatd subject may read tokens but must not + // hold personal-write access anywhere. + chatdCtx := dbauthz.AsChatd(context.Background()) + _, err = db.GetMCPServerUserToken(chatdCtx, database.GetMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + }) + requireAuthorized(t, err) + requireNotAuthorized(t, markFailure(chatdCtx)) + + // The per-user token-owner subject writes only its own + // owner's rows. + requireNotAuthorized(t, markFailure(dbauthz.AsChatdTokenOwner(context.Background(), stranger.ID))) + require.NoError(t, markFailure(dbauthz.AsChatdTokenOwner(context.Background(), owner.ID))) + }) + for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index d2c5e6da8bc..32756525d3f 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -75,8 +75,9 @@ const ( SubjectTypeSystemReadProvisionerDaemons SubjectType = "system_read_provisioner_daemons" SubjectTypeSystemRestricted SubjectType = "system_restricted" SubjectTypeSystemOAuth SubjectType = "system_oauth" - SubjectTypeAPIKeyRevoker SubjectType = "api_key_revoker" // #nosec G101, not a credential. - SubjectTypeChatdKeyMinter SubjectType = "chatd_key_minter" // #nosec G101, not a credential. + SubjectTypeAPIKeyRevoker SubjectType = "api_key_revoker" // #nosec G101, not a credential. + SubjectTypeChatdKeyMinter SubjectType = "chatd_key_minter" // #nosec G101, not a credential. + SubjectTypeChatdTokenOwner SubjectType = "chatd_token_owner" // #nosec G101, not a credential. SubjectTypeNotifier SubjectType = "notifier" SubjectTypeSubAgentAPI SubjectType = "sub_agent_api" SubjectTypeFileReader SubjectType = "file_reader" diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 97284ca1d67..77c051ccb4c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5238,8 +5238,10 @@ func (p *Server) refreshMCPTokenIfNeeded( expiry = sql.NullTime{Time: result.Expiry, Valid: true} } + // The chatd subject has no personal-write access; persist the + // refresh as a subject scoped to this token's owner. updated, err := p.db.UpdateMCPServerUserTokenFromRefresh( - ctx, + dbauthz.AsChatdTokenOwner(ctx, tok.UserID), database.UpdateMCPServerUserTokenFromRefreshParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -5312,8 +5314,10 @@ func (p *Server) markMCPTokenRefreshFailure( slog.Error(refreshErr), ) + // The chatd subject has no personal-write access; persist the + // failure as a subject scoped to this token's owner. marked, err := p.db.MarkMCPServerUserTokenRefreshFailure( - ctx, + dbauthz.AsChatdTokenOwner(ctx, tok.UserID), database.MarkMCPServerUserTokenRefreshFailureParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, From 4100fe2285631235fb3c5098501625b96b40aa6f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:20:27 +0000 Subject: [PATCH 06/11] fix(coderd): drop unneeded read grant from the chatd token owner subject Both production writes fetch the token row through the underlying store and authorize only ActionUpdatePersonal, so the subject never needs ActionReadPersonal. Pin the write-only shape in the real-authorizer test and use test contexts instead of context.Background. --- coderd/database/dbauthz/dbauthz.go | 7 +++-- coderd/database/dbauthz/mcptokensauth_test.go | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1a9734938ee..b58679766fc 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -495,8 +495,9 @@ var ( } // subjectChatdTokenOwner persists MCP OAuth2 token refreshes and - // refresh failures for one chat owner. User-level permissions keep - // the write scoped to that owner's rows. + // refresh failures for one chat owner. The write-only user-level + // permission keeps the write scoped to that owner's rows; reads go + // through the daemon-wide chatd subject. subjectChatdTokenOwner = func(userID uuid.UUID) rbac.Subject { return rbac.Subject{ Type: rbac.SubjectTypeChatdTokenOwner, @@ -508,7 +509,7 @@ var ( DisplayName: "Chatd Token Owner", Site: []rbac.Permission{}, User: rbac.Permissions(map[string][]policy.Action{ - rbac.ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, + rbac.ResourceUser.Type: {policy.ActionUpdatePersonal}, }), ByOrgID: map[string]rbac.OrgPermissions{}, }, diff --git a/coderd/database/dbauthz/mcptokensauth_test.go b/coderd/database/dbauthz/mcptokensauth_test.go index 948db2de385..770e2cdd027 100644 --- a/coderd/database/dbauthz/mcptokensauth_test.go +++ b/coderd/database/dbauthz/mcptokensauth_test.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/testutil" ) // TestMCPServerUserTokensAuth exercises the owner-personal gating of @@ -23,6 +24,7 @@ import ( func TestMCPServerUserTokensAuth(t *testing.T) { t.Parallel() + setupCtx := testutil.Context(t, testutil.WaitLong) authz := rbac.NewAuthorizer(prometheus.NewRegistry()) store, _ := dbtestutil.NewDB(t) db := dbauthz.New(store, authz, slogtest.Make(t, &slogtest.Options{ @@ -39,7 +41,7 @@ func TestMCPServerUserTokensAuth(t *testing.T) { return rbac.Subject{ ID: userID, Roles: must(rolestore.Expand( - context.Background(), + setupCtx, store, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID)}, )), @@ -88,10 +90,11 @@ func TestMCPServerUserTokensAuth(t *testing.T) { t.Run("ChatdSubjects", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) cfg := dbgen.MCPServerConfig(t, store, database.MCPServerConfig{ OrganizationID: org.ID, }) - token, err := store.UpsertMCPServerUserToken(context.Background(), database.UpsertMCPServerUserTokenParams{ + token, err := store.UpsertMCPServerUserToken(ctx, database.UpsertMCPServerUserTokenParams{ MCPServerConfigID: cfg.ID, UserID: owner.ID, AccessToken: "chatd-access-token", @@ -110,7 +113,7 @@ func TestMCPServerUserTokensAuth(t *testing.T) { // The daemon-wide chatd subject may read tokens but must not // hold personal-write access anywhere. - chatdCtx := dbauthz.AsChatd(context.Background()) + chatdCtx := dbauthz.AsChatd(ctx) _, err = db.GetMCPServerUserToken(chatdCtx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: cfg.ID, UserID: owner.ID, @@ -118,10 +121,21 @@ func TestMCPServerUserTokensAuth(t *testing.T) { requireAuthorized(t, err) requireNotAuthorized(t, markFailure(chatdCtx)) + // The token-owner subject is write-only: reads belong to the + // daemon-wide chatd subject. + _, err = db.GetMCPServerUserToken( + dbauthz.AsChatdTokenOwner(ctx, owner.ID), + database.GetMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + }, + ) + requireNotAuthorized(t, err) + // The per-user token-owner subject writes only its own // owner's rows. - requireNotAuthorized(t, markFailure(dbauthz.AsChatdTokenOwner(context.Background(), stranger.ID))) - require.NoError(t, markFailure(dbauthz.AsChatdTokenOwner(context.Background(), owner.ID))) + requireNotAuthorized(t, markFailure(dbauthz.AsChatdTokenOwner(ctx, stranger.ID))) + require.NoError(t, markFailure(dbauthz.AsChatdTokenOwner(ctx, owner.ID))) }) for _, tc := range testCases { @@ -130,10 +144,11 @@ func TestMCPServerUserTokensAuth(t *testing.T) { // A per-case config keeps parallel subtests from sharing // token rows; the raw store seeds bypassing authz. + testCtx := testutil.Context(t, testutil.WaitLong) cfg := dbgen.MCPServerConfig(t, store, database.MCPServerConfig{ OrganizationID: org.ID, }) - _, err := store.UpsertMCPServerUserToken(context.Background(), database.UpsertMCPServerUserTokenParams{ + _, err := store.UpsertMCPServerUserToken(testCtx, database.UpsertMCPServerUserTokenParams{ MCPServerConfigID: cfg.ID, UserID: owner.ID, AccessToken: "seed-access-token", @@ -141,7 +156,7 @@ func TestMCPServerUserTokensAuth(t *testing.T) { }) require.NoError(t, err) - ctx := dbauthz.As(context.Background(), tc.Subject) + ctx := dbauthz.As(testCtx, tc.Subject) _, err = db.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: cfg.ID, From 50a0b8956ed409d82531584febe06558ff157065 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:34:34 +0000 Subject: [PATCH 07/11] chore: tighten token owner subject comment per cleanup gate --- coderd/database/dbauthz/dbauthz.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b58679766fc..77ab7dc5ae5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -494,10 +494,9 @@ var ( }.WithCachedASTValue() } - // subjectChatdTokenOwner persists MCP OAuth2 token refreshes and - // refresh failures for one chat owner. The write-only user-level - // permission keeps the write scoped to that owner's rows; reads go - // through the daemon-wide chatd subject. + // MCP token refresh writes use this owner-scoped subject so the shared + // chatd context cannot update arbitrary users' personal data. Reads use + // the daemon-wide chatd subject. subjectChatdTokenOwner = func(userID uuid.UUID) rbac.Subject { return rbac.Subject{ Type: rbac.SubjectTypeChatdTokenOwner, From 8dbe419d92b22aad756b49fc33944bd603aa40e1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:45:12 +0000 Subject: [PATCH 08/11] fix(coderd): handle scoped MCP token denial --- coderd/mcp.go | 3 ++- coderd/mcp_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 099ade5fcba..87fc192d9d2 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -552,7 +552,8 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { }) if err == nil { sdkConfig.AuthConnected = api.refreshMCPUserToken(ctx, config, tok) - } else if !errors.Is(err, sql.ErrNoRows) { + // Token visibility is separately scoped, so denial means disconnected here. + } else if !errors.Is(err, sql.ErrNoRows) && !dbauthz.IsNotAuthorizedError(err) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to get user token.", Detail: err.Error(), diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index c2ca25c36ac..6055faf3a2c 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -705,6 +705,57 @@ func TestMCPServerConfigsScopedKeyFullView(t *testing.T) { } } +func TestMCPServerConfigScopedKeyWithoutPersonalTokenRead(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) + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "OAuth Server", + Slug: "oauth-server-scoped-key", + Transport: "streamable_http", + URL: "https://mcp.example.com/oauth", + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: "https://auth.example.com/token", + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + + //nolint:gocritic // Seeding token state requires system access. + _, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: created.ID, + UserID: firstUser.UserID, + AccessToken: "access-token", + TokenType: "Bearer", + Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, + }) + require.NoError(t, err) + + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: firstUser.UserID, + Scopes: database.APIKeyScopes{ + "mcp_server_config:read", + "organization:read", + }, + }) + scopedClient := codersdk.New(adminClient.URL) + scopedClient.SetSessionToken(token) + + config, err := scopedClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) + require.NoError(t, err) + require.False(t, config.AuthConnected) +} + func TestMCPServerConfigACL(t *testing.T) { t.Parallel() From 791d4de9fbc00f9586d20f784aefc07c5845b139 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:56:35 +0000 Subject: [PATCH 09/11] fix(coderd): clarify scoped MCP auth status --- coderd/mcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 87fc192d9d2..0feeedef706 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -543,8 +543,8 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { sdkConfig = convertMCPServerConfigRedacted(config) } - // Populate AuthConnected for the calling user. Attempt to - // refresh the token so the status is accurate. + // Refresh readable token state so AuthConnected reflects the current + // OAuth status. if config.AuthType == "oauth2" { tok, err := api.Database.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{ MCPServerConfigID: config.ID, From 4ad56967ab1e7fca1ccd0cb6f6e498146b82df61 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:29:01 +0000 Subject: [PATCH 10/11] fix(coderd): persist scoped MCP token refreshes --- coderd/mcp.go | 17 ++++-- coderd/mcp_test.go | 126 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 0feeedef706..cae15ca8a6f 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -1369,6 +1369,10 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques httpapi.Write(ctx, rw, http.StatusOK, codersdk.MCPServerOAuth2DisconnectResponse{}) return } + if dbauthz.IsNotAuthorizedError(err) { + httpapi.Forbidden(rw) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to disconnect OAuth2 token.", Detail: err.Error(), @@ -1439,8 +1443,13 @@ func (api *API) refreshMCPUserToken( expiry = sql.NullTime{Time: result.Expiry, Valid: true} } + // The caller can read the token, but a read-only key cannot persist refresh + // results. System access permits storing rotated credentials after a + // successful refresh. + //nolint:gocritic // Token refresh persistence follows an authorized read of the same token. + persistCtx := dbauthz.AsSystemRestricted(ctx) _, err = api.Database.UpdateMCPServerUserTokenFromRefresh( - ctx, + persistCtx, database.UpdateMCPServerUserTokenFromRefreshParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -1474,8 +1483,9 @@ func (api *API) currentMCPUserTokenConnected( ctx context.Context, tok database.MCPServerUserToken, ) (bool, error) { + //nolint:gocritic // Refresh reconciliation follows an authorized read of the same token. current, err := api.Database.GetMCPServerUserToken( - ctx, + dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ MCPServerConfigID: tok.MCPServerConfigID, UserID: tok.UserID, @@ -1502,8 +1512,9 @@ func (api *API) markMCPTokenRefreshFailure( tok database.MCPServerUserToken, refreshErr error, ) bool { + //nolint:gocritic // Refresh failure recording follows an authorized read of the same token. _, err := api.Database.MarkMCPServerUserTokenRefreshFailure( - ctx, + dbauthz.AsSystemRestricted(ctx), database.MarkMCPServerUserTokenRefreshFailureParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 6055faf3a2c..6b6df20f6f5 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -756,6 +756,102 @@ func TestMCPServerConfigScopedKeyWithoutPersonalTokenRead(t *testing.T) { require.False(t, config.AuthConnected) } +func TestMCPServerConfigScopedKeyRefreshPersistsRotatedToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authConnected func(*testing.T, context.Context, *codersdk.Client, uuid.UUID, uuid.UUID) bool + }{ + { + name: "List", + authConnected: func(t *testing.T, ctx context.Context, client *codersdk.Client, organizationID, _ uuid.UUID) bool { + configs, err := client.MCPServerConfigs(ctx, organizationID) + require.NoError(t, err) + require.Len(t, configs, 1) + return configs[0].AuthConnected + }, + }, + { + name: "Get", + authConnected: func(t *testing.T, ctx context.Context, client *codersdk.Client, organizationID, configID uuid.UUID) bool { + config, err := client.MCPServerConfigByID(ctx, organizationID, configID) + require.NoError(t, err) + return config.AuthConnected + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + require.Equal(t, "old-refresh", r.Form.Get("refresh_token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"rotated-refresh","token_type":"Bearer","expires_in":3600}`)) + })) + t.Cleanup(tokenSrv.Close) + + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "OAuth Refresh " + test.name, + Slug: "oauth-refresh-" + strings.ToLower(test.name), + Transport: "streamable_http", + URL: "https://mcp.example.com/refresh", + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: tokenSrv.URL, + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + + //nolint:gocritic // Seeding token state requires system access. + _, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: created.ID, + UserID: firstUser.UserID, + AccessToken: "expired-access", + RefreshToken: "old-refresh", + TokenType: "Bearer", + Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true}, + }) + require.NoError(t, err) + + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: firstUser.UserID, + Scopes: database.APIKeyScopes{ + database.ApiKeyScopeMcpServerConfigRead, + database.ApiKeyScopeOrganizationRead, + database.ApiKeyScopeUserReadPersonal, + }, + }) + scopedClient := codersdk.New(adminClient.URL) + scopedClient.SetSessionToken(token) + + require.True(t, test.authConnected(t, ctx, scopedClient, created.OrganizationID, created.ID)) + + //nolint:gocritic // Verifying persisted state requires system access. + row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + MCPServerConfigID: created.ID, + UserID: firstUser.UserID, + }) + require.NoError(t, err) + require.Equal(t, "fresh-access", row.AccessToken) + require.Equal(t, "rotated-refresh", row.RefreshToken) + }) + } +} + func TestMCPServerConfigACL(t *testing.T) { t.Parallel() @@ -1687,6 +1783,36 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { require.ErrorIs(t, err, sql.ErrNoRows) } + t.Run("ScopedKeyWithoutPersonalTokenUpdate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-scoped-key", "") + seedToken(t, db, configID, memberID) + + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: memberID, + Scopes: database.APIKeyScopes{ + database.ApiKeyScopeUserReadPersonal, + }, + }) + scopedClient := codersdk.New(memberClient.URL) + scopedClient.SetSessionToken(token) + + _, err := scopedClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + //nolint:gocritic // Verifying persisted state requires system access. + row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ + MCPServerConfigID: configID, + UserID: memberID, + }) + require.NoError(t, err) + require.Equal(t, "refresh-token", row.RefreshToken) + }) + t.Run("NoToken", func(t *testing.T) { t.Parallel() From eed5e05a7b95f877c5a3f25d4a6d913678f848a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:16:44 +0000 Subject: [PATCH 11/11] fix(coderd): authorize personal token writes before MCP OAuth side effects --- coderd/mcp.go | 46 +++++++++++++----- coderd/mcp_test.go | 116 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index cae15ca8a6f..7ba73157c54 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -214,16 +214,20 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) { return } - // Look up the calling user's OAuth2 tokens so we can populate - // auth_connected per server. Attempt to refresh expired tokens - // so the status is accurate and the token is ready for use. - userTokens, err := api.Database.GetMCPServerUserTokensByUserID(ctx, apiKey.UserID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get user tokens.", - Detail: err.Error(), - }) - return + // Read and refresh OAuth2 tokens only when the caller may view them. + // Without that permission, auth_connected remains false. + var userTokens []database.MCPServerUserToken + tokenReadErr := api.HTTPAuth.Authorizer.Authorize(ctx, httpmw.UserAuthorization(ctx), + policy.ActionReadPersonal, rbac.ResourceUserObject(apiKey.UserID).RBACObject()) + if tokenReadErr == nil { + userTokens, err = api.Database.GetMCPServerUserTokensByUserID(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get user tokens.", + Detail: err.Error(), + }) + return + } } // Build a config lookup for the refresh helper. @@ -1056,6 +1060,12 @@ func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request) return } + // Do not start an authorization flow when its grant cannot be persisted. + if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceUserObject(httpmw.APIKey(r).UserID)) { + httpapi.Forbidden(rw) + return + } + // Build the authorization URL. The frontend opens this in a popup. // The callback URL is on our server; after the exchange we store // the token and close the popup. @@ -1126,6 +1136,14 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request) if !ok { return } + + // Authorization codes are one-time credentials. Do not consume one when + // the resulting token cannot be persisted. + if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceUserObject(apiKey.UserID)) { + httpapi.Forbidden(rw) + return + } + config, err := api.Database.GetMCPServerConfigByID(ctx, mcpServerID) if err != nil { if httpapi.Is404Error(err) { @@ -1329,13 +1347,19 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques return } + if !api.Authorize(r, policy.ActionUpdatePersonal, rbac.ResourceUserObject(apiKey.UserID)) { + httpapi.Forbidden(rw) + return + } + var ( config database.MCPServerConfig token database.MCPServerUserToken ) // Serializable isolation keeps the revoked token aligned with the row deleted locally. err := api.Database.InTx(func(tx database.Store) error { - dbToken, err := tx.GetMCPServerUserToken(ctx, database.GetMCPServerUserTokenParams{ + //nolint:gocritic // Update permission permits this revocation read. + dbToken, err := tx.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ MCPServerConfigID: configID, UserID: apiKey.UserID, }) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 6b6df20f6f5..d2a4f93b2ac 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -751,6 +751,11 @@ func TestMCPServerConfigScopedKeyWithoutPersonalTokenRead(t *testing.T) { scopedClient := codersdk.New(adminClient.URL) scopedClient.SetSessionToken(token) + configs, err := scopedClient.MCPServerConfigs(ctx, created.OrganizationID) + require.NoError(t, err) + require.Len(t, configs, 1) + require.False(t, configs[0].AuthConnected) + config, err := scopedClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID) require.NoError(t, err) require.False(t, config.AuthConnected) @@ -1485,6 +1490,96 @@ func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) { }) } +func TestMCPServerConfigsOAuth2ScopedKeyAuthorization(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + var exchangeRequests atomic.Int64 + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + exchangeRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"scoped-access-token","token_type":"Bearer"}`)) + })) + t.Cleanup(tokenServer.Close) + + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + firstUser := coderdtest.CreateFirstUser(t, adminClient) + created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "OAuth Scoped Key", + Slug: "oauth-scoped-key", + Transport: "streamable_http", + URL: "https://mcp.example.com/oauth-scoped-key", + AuthType: "oauth2", + OAuth2ClientID: "cid", + OAuth2AuthURL: "https://auth.example.com/authorize", + OAuth2TokenURL: tokenServer.URL, + Availability: "default_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: firstUser.UserID, + Scopes: database.APIKeyScopes{ + database.ApiKeyScopeMcpServerConfigRead, + database.ApiKeyScopeOrganizationRead, + database.ApiKeyScopeUserReadPersonal, + }, + }) + scopedClient := codersdk.New(adminClient.URL) + scopedClient.SetSessionToken(token) + scopedClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + + t.Run("ConnectRequiresPersonalTokenUpdate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + connectURL := scopedClient.MCPServerOAuth2ConnectURL(created.OrganizationID, created.ID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, connectURL, nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: scopedClient.SessionToken()}) + + res, err := scopedClient.HTTPClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusForbidden, res.StatusCode) + }) + + t.Run("CallbackRequiresPersonalTokenUpdateBeforeExchange", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + state := "scoped-key-state" + callbackURL, err := scopedClient.URL.Parse( + "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback", + ) + require.NoError(t, err) + query := callbackURL.Query() + query.Set("code", "one-time-code") + query.Set("state", state) + callbackURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: scopedClient.SessionToken()}) + req.AddCookie(&http.Cookie{Name: "mcp_oauth2_state_" + created.ID.String(), Value: state}) + + res, err := scopedClient.HTTPClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusForbidden, res.StatusCode) + require.Zero(t, exchangeRequests.Load()) + }) +} + func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) { t.Parallel() @@ -1813,6 +1908,27 @@ func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) { require.Equal(t, "refresh-token", row.RefreshToken) }) + t.Run("ScopedKeyWithPersonalTokenUpdateOnly", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-update-only", "") + seedToken(t, db, configID, memberID) + + _, token := dbgen.APIKey(t, db, database.APIKey{ + UserID: memberID, + Scopes: database.APIKeyScopes{ + database.ApiKeyScopeUserUpdatePersonal, + }, + }) + scopedClient := codersdk.New(memberClient.URL) + scopedClient.SetSessionToken(token) + + _, err := scopedClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID) + require.NoError(t, err) + requireTokenDeleted(t, db, configID, memberID) + }) + t.Run("NoToken", func(t *testing.T) { t.Parallel()