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

## Summary

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

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

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

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

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

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

183 lines
5.5 KiB
Go

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)
})
}
}