fix(coderd): prevent cross-tenant workspace app rebinding (#26103)

This commit is contained in:
dylanhuff-at-coder
2026-06-11 15:02:41 -04:00
committed by GitHub
parent 112c921235
commit f3c7c23623
7 changed files with 751 additions and 2 deletions
+8 -1
View File
@@ -259,8 +259,9 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
slugHashEnc := base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(slugHash[:])
computedSlug := strings.ToLower(slugHashEnc[:8]) + "-" + app.Slug
appID := uuid.New()
_, err := a.Database.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: uuid.New(), // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is.
ID: appID, // NOTE: we may need to maintain the app's ID here for stability, but for now we'll leave this as-is.
CreatedAt: createdAt,
AgentID: subAgent.ID,
Slug: computedSlug,
@@ -291,6 +292,12 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
Tooltip: "", // tooltips are not currently supported in subagent workspaces, default to empty string
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// The upsert's ON CONFLICT guard refused to rebind an
// existing workspace-owned app to an agent outside that
// workspace, including agents that resolve to no workspace.
return xerrors.Errorf("workspace app slug %q with ID %q is already bound to a workspace-owned agent and cannot be rebound to an agent in another workspace or to an agent without a workspace; refusing to rebind to agent ID %q", computedSlug, appID, subAgent.ID)
}
return xerrors.Errorf("insert workspace app: %w", err)
}
+77
View File
@@ -12,6 +12,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/proto"
@@ -19,6 +20,7 @@ import (
"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/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -804,6 +806,81 @@ func TestSubAgentAPI(t *testing.T) {
})
})
t.Run("CreateSubAgentWithAppRebindRejected", func(t *testing.T) {
t.Parallel()
clock := quartz.NewMock(t)
createdAt := clock.Now()
parentAgent := database.WorkspaceAgent{
ID: uuid.New(),
ResourceID: uuid.New(),
ConnectionTimeoutSeconds: 30,
TroubleshootingURL: "https://example.com/troubleshoot",
APIKeyScope: database.AgentKeyScopeEnumAll,
}
workspace := database.Workspace{
ID: uuid.New(),
TemplateID: uuid.New(),
}
template := database.Template{
ID: workspace.TemplateID,
MaxPortSharingLevel: database.AppSharingLevelPublic,
}
insertedSubAgent := database.WorkspaceAgent{
ID: uuid.New(),
ParentID: uuid.NullUUID{UUID: parentAgent.ID, Valid: true},
ResourceID: parentAgent.ResourceID,
Name: "child-agent",
AuthToken: uuid.New(),
}
dbM := dbmock.NewMockStore(gomock.NewController(t))
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), parentAgent.ID).Return(workspace, nil)
dbM.EXPECT().GetTemplateByID(gomock.Any(), workspace.TemplateID).Return(template, nil)
dbM.EXPECT().InsertWorkspaceAgent(gomock.Any(), gomock.Cond(func(params database.InsertWorkspaceAgentParams) bool {
return params.ParentID.Valid && params.ParentID.UUID == parentAgent.ID &&
params.ResourceID == parentAgent.ResourceID &&
params.Name == insertedSubAgent.Name
})).Return(insertedSubAgent, nil)
dbM.EXPECT().UpsertWorkspaceApp(gomock.Any(), gomock.Cond(func(params database.UpsertWorkspaceAppParams) bool {
return params.ID != uuid.Nil &&
params.AgentID == insertedSubAgent.ID &&
params.CreatedAt.Equal(createdAt) &&
params.Slug == "fdqf0lpd-code-server" &&
params.DisplayName == "VS Code"
})).Return(database.WorkspaceApp{}, sql.ErrNoRows)
api := &agentapi.SubAgentAPI{
OwnerID: uuid.New(),
OrganizationID: uuid.New(),
AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return parentAgent, nil },
Clock: clock,
Database: dbM,
Log: testutil.Logger(t),
}
createResp, err := api.CreateSubAgent(context.Background(), &proto.CreateSubAgentRequest{
Name: insertedSubAgent.Name,
Directory: "/workspaces/coder",
Architecture: "amd64",
OperatingSystem: "linux",
Apps: []*proto.CreateSubAgentRequest_App{
{
Slug: "code-server",
DisplayName: ptr.Ref("VS Code"),
},
},
})
require.NoError(t, err)
require.Len(t, createResp.AppCreationErrors, 1)
require.Equal(t, int32(0), createResp.AppCreationErrors[0].Index)
require.Nil(t, createResp.AppCreationErrors[0].Field)
require.Contains(t, createResp.AppCreationErrors[0].Error, "workspace app slug \"fdqf0lpd-code-server\"")
require.Contains(t, createResp.AppCreationErrors[0].Error, "already bound to a workspace-owned agent")
require.Contains(t, createResp.AppCreationErrors[0].Error, "cannot be rebound to an agent in another workspace or to an agent without a workspace")
require.NotContains(t, createResp.AppCreationErrors[0].Error, "sql: no rows in result set")
})
t.Run("DeleteSubAgent", func(t *testing.T) {
t.Parallel()