mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
fix: gate MCP user tokens on owner-scoped personal RBAC (#27945)
Re-gates every `mcp_server_user_tokens` dbauthz method from `rbac.ResourceDeploymentConfig` onto owner-scoped personal actions, mirroring the `external_auth_links` prior art. These are per-user credential rows, not deployment config, and the old gate forced every call site to elevate with `dbauthz.AsSystemRestricted`. ## Summary - Reads gate on `ActionReadPersonal` and writes on `ActionUpdatePersonal` against the token owner (`MCPServerUserToken.RBACObject()` derives from `user_id`). The id-keyed refresh and refresh-failure mutations resolve the owner via a `GetMCPServerUserTokenByID` prefetch inside dbauthz; the mutation queries cannot retarget a row's owner, so there is no fetch-authorize race. - All seven token-path `AsSystemRestricted` elevations in `coderd/mcp.go` and all four in chatd are removed along with their `//nolint:gocritic` markers; handlers use the request actor. - The daemon-wide chatd subject keeps only `ActionReadPersonal` on `ResourceUser`. Background token refresh and failure persistence use a new per-user `AsChatdTokenOwner` subject holding only user-level `ActionUpdatePersonal` scoped to the token owner (both mutations resolve the row via the underlying store, so the subject needs no read grant), and chatd never holds site-wide personal-write access. `TestAsChatd` pins the complete `ResourceUser` action set and `TestMCPServerUserTokensAuth` exercises both subjects against the real authorizer. - New strict-authorizer test (`rbac.NewAuthorizer` against real rows): owner passes get/upsert/delete, a stranger fails, and an org admin of another org fails. No user-visible behavior change. Stacked on #27944. Completes the MCP org-separation stack. Closes https://linear.app/codercom/issue/CODAGT-806 UAT: regression pass on a dogfood instance: OAuth2 connect redirect, member list/status with redaction, self-scoped disconnect (cannot touch another user's token), end-to-end chat MCP tool invocation, and lower-stack audit/ACL smoke checks. > Mux (AI agent) authored this PR on Mike's behalf. <!-- mux-attribution: model=claude-fable-5 thinking=high -->
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
Generated
+15
@@ -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()
|
||||
|
||||
@@ -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{
|
||||
|
||||
Generated
+1
@@ -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.
|
||||
|
||||
Generated
+29
@@ -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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user