diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 31906ab90ae..77ab7dc5ae5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -494,6 +494,29 @@ var ( }.WithCachedASTValue() } + // 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, + 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.ActionUpdatePersonal}, + }), + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + } + subjectSystemRestricted = rbac.Subject{ Type: rbac.SubjectTypeSystemRestricted, FriendlyName: "System", @@ -795,7 +818,10 @@ var ( rbac.ResourceWorkspace.Type: {policy.ActionRead, policy.ActionUpdate}, rbac.ResourceDeploymentConfig.Type: {policy.ActionRead}, rbac.ResourceMCPServerConfig.Type: {policy.ActionRead}, - rbac.ResourceUser.Type: {policy.ActionReadPersonal}, + // 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{}, @@ -930,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 @@ -2295,10 +2327,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 +4113,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 +7019,10 @@ 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 + 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 +7714,10 @@ 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 + 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 +9069,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..fa784ded25b 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) })) } @@ -7602,9 +7612,18 @@ 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") + // Pin the complete ResourceUser action set: read_personal (user + // 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 { + 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..770e2cdd027 --- /dev/null +++ b/coderd/database/dbauthz/mcptokensauth_test.go @@ -0,0 +1,182 @@ +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" + "github.com/coder/coder/v2/testutil" +) + +// 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() + + 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{ + 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( + setupCtx, + 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, + }, + } + + 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(ctx, 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(ctx) + _, err = db.GetMCPServerUserToken(chatdCtx, database.GetMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + }) + 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(ctx, stranger.ID))) + require.NoError(t, markFailure(dbauthz.AsChatdTokenOwner(ctx, owner.ID))) + }) + + 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. + testCtx := testutil.Context(t, testutil.WaitLong) + cfg := dbgen.MCPServerConfig(t, store, database.MCPServerConfig{ + OrganizationID: org.ID, + }) + _, err := store.UpsertMCPServerUserToken(testCtx, database.UpsertMCPServerUserTokenParams{ + MCPServerConfigID: cfg.ID, + UserID: owner.ID, + AccessToken: "seed-access-token", + TokenType: "bearer", + }) + require.NoError(t, err) + + ctx := dbauthz.As(testCtx, 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) + }) + } +} 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..d3e0834ccf8 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -661,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{ 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..7ba73157c54 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 or ResourceUser.UpdatePersonal, so DB 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. @@ -219,17 +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. - //nolint:gocritic // Token authorization is handled separately from config RBAC. - userTokens, err := api.Database.GetMCPServerUserTokensByUserID(dbauthz.AsSystemRestricted(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. @@ -549,17 +547,17 @@ 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" { - //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, }) 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(), @@ -1062,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. @@ -1132,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) { @@ -1273,8 +1285,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 +1347,19 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques return } - //nolint:gocritic // Users manage their own tokens. - systemCtx := dbauthz.AsSystemRestricted(ctx) + 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(systemCtx, database.GetMCPServerUserTokenParams{ + //nolint:gocritic // Update permission permits this revocation read. + dbToken, err := tx.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ MCPServerConfigID: configID, UserID: apiKey.UserID, }) @@ -1356,11 +1371,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 { @@ -1377,6 +1393,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(), @@ -1447,10 +1467,13 @@ 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. + // 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( - dbauthz.AsSystemRestricted(ctx), + persistCtx, database.UpdateMCPServerUserTokenFromRefreshParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -1484,7 +1507,7 @@ func (api *API) currentMCPUserTokenConnected( ctx context.Context, tok database.MCPServerUserToken, ) (bool, error) { - //nolint:gocritic // Reading the current token requires system access. + //nolint:gocritic // Refresh reconciliation follows an authorized read of the same token. current, err := api.Database.GetMCPServerUserToken( dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{ @@ -1513,8 +1536,7 @@ func (api *API) markMCPTokenRefreshFailure( tok database.MCPServerUserToken, refreshErr error, ) bool { - //nolint:gocritic // Need system-level write access to persist - // the refresh failure. + //nolint:gocritic // Refresh failure recording follows an authorized read of the same token. _, err := api.Database.MarkMCPServerUserTokenRefreshFailure( dbauthz.AsSystemRestricted(ctx), database.MarkMCPServerUserTokenRefreshFailureParams{ diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index c2ca25c36ac..d2a4f93b2ac 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -705,6 +705,158 @@ 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) + + 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) +} + +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() @@ -1338,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() @@ -1636,6 +1878,57 @@ 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("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() 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 b54a273c3ba..77c051ccb4c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5238,10 +5238,10 @@ 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. + // 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( - dbauthz.AsSystemRestricted(ctx), + dbauthz.AsChatdTokenOwner(ctx, tok.UserID), database.UpdateMCPServerUserTokenFromRefreshParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -5256,9 +5256,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 +5314,10 @@ func (p *Server) markMCPTokenRefreshFailure( slog.Error(refreshErr), ) - //nolint:gocritic // Chatd needs system-level write access to - // persist the refresh failure for the user. + // 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( - dbauthz.AsSystemRestricted(ctx), + dbauthz.AsChatdTokenOwner(ctx, tok.UserID), database.MarkMCPServerUserTokenRefreshFailureParams{ ID: tok.ID, UpdatedAt: tok.UpdatedAt, @@ -5333,10 +5332,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..996a384ab6d 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -1640,6 +1640,24 @@ 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) + + 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)