◐ Shell
clean mode source ↗

fix(coderd): prevent cross-tenant workspace app rebinding (#26103) (#… · coder/coder@c05b4d9

@@ -6076,6 +6076,178 @@ func TestWorkspaceAgentNameUniqueTrigger(t *testing.T) {

60766076

})

60776077

}

607860786079+

func TestUpsertWorkspaceAppCannotRebindAcrossWorkspaces(t *testing.T) {

6080+

t.Parallel()

6081+6082+

db, _ := dbtestutil.NewDB(t)

6083+

org := dbgen.Organization(t, db, database.Organization{})

6084+

ctx := testutil.Context(t, testutil.WaitShort)

6085+6086+

// createWorkspace builds the owner -> template -> version -> workspace chain

6087+

// and returns the workspace plus its template version so callers can create

6088+

// additional builds (and thus agents) within the same workspace.

6089+

createWorkspace := func(t *testing.T) (database.WorkspaceTable, uuid.UUID) {

6090+

t.Helper()

6091+

user := dbgen.User(t, db, database.User{})

6092+

template := dbgen.Template(t, db, database.Template{

6093+

OrganizationID: org.ID,

6094+

CreatedBy: user.ID,

6095+

})

6096+

version := dbgen.TemplateVersion(t, db, database.TemplateVersion{

6097+

TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID},

6098+

OrganizationID: org.ID,

6099+

CreatedBy: user.ID,

6100+

})

6101+

workspace := dbgen.Workspace(t, db, database.WorkspaceTable{

6102+

OrganizationID: org.ID,

6103+

TemplateID: template.ID,

6104+

OwnerID: user.ID,

6105+

})

6106+

return workspace, version.ID

6107+

}

6108+6109+

// addAgent creates a build, resource, and agent for the workspace. The

6110+

// build's JobID matches the resource's JobID so the upsert's

6111+

// agent -> resource -> workspace_builds(job_id) -> workspace_id traversal

6112+

// resolves to the workspace.

6113+

addAgent := func(t *testing.T, workspace database.WorkspaceTable, versionID uuid.UUID, buildNumber int32) database.WorkspaceAgent {

6114+

t.Helper()

6115+

job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{

6116+

Type: database.ProvisionerJobTypeWorkspaceBuild,

6117+

OrganizationID: org.ID,

6118+

})

6119+

dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{

6120+

BuildNumber: buildNumber,

6121+

JobID: job.ID,

6122+

WorkspaceID: workspace.ID,

6123+

TemplateVersionID: versionID,

6124+

})

6125+

resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{

6126+

JobID: job.ID,

6127+

})

6128+

return dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{

6129+

ResourceID: resource.ID,

6130+

})

6131+

}

6132+6133+

upsertApp := func(appID, agentID uuid.UUID, slug string) (database.WorkspaceApp, error) {

6134+

return db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{

6135+

ID: appID,

6136+

CreatedAt: dbtime.Now(),

6137+

AgentID: agentID,

6138+

Slug: slug,

6139+

DisplayName: "Code Server",

6140+

Icon: "/icon.png",

6141+

SharingLevel: database.AppSharingLevelOwner,

6142+

Health: database.WorkspaceAppHealthDisabled,

6143+

OpenIn: database.WorkspaceAppOpenInSlimWindow,

6144+

})

6145+

}

6146+6147+

// Given: two independent workspaces, each with an agent that resolves to its

6148+

// own workspace.

6149+

workspaceA, versionA := createWorkspace(t)

6150+

workspaceB, versionB := createWorkspace(t)

6151+

agentA := addAgent(t, workspaceA, versionA, 1)

6152+

agentB := addAgent(t, workspaceB, versionB, 1)

6153+6154+

gotA, err := db.GetWorkspaceByAgentID(ctx, agentA.ID)

6155+

require.NoError(t, err)

6156+

require.Equal(t, workspaceA.ID, gotA.ID)

6157+

gotB, err := db.GetWorkspaceByAgentID(ctx, agentB.ID)

6158+

require.NoError(t, err)

6159+

require.Equal(t, workspaceB.ID, gotB.ID)

6160+6161+

appID := uuid.New()

6162+

const originalSlug = "code-server"

6163+6164+

// Initial insert under workspace A's agent succeeds (no conflict).

6165+

app, err := upsertApp(appID, agentA.ID, originalSlug)

6166+

require.NoError(t, err)

6167+

require.Equal(t, appID, app.ID)

6168+

require.Equal(t, agentA.ID, app.AgentID)

6169+

require.Equal(t, originalSlug, app.Slug)

6170+6171+

// Upserting the same app id onto workspace B's agent is rejected because the

6172+

// existing row and the incoming agent resolve to different workspaces. The

6173+

// guard updates zero rows, so the :one query returns sql.ErrNoRows.

6174+

_, err = upsertApp(appID, agentB.ID, "hijacked")

6175+

require.ErrorIs(t, err, sql.ErrNoRows)

6176+6177+

// The app remains bound to workspace A's agent, unchanged.

6178+

appsA, err := db.GetWorkspaceAppsByAgentID(ctx, agentA.ID)

6179+

require.NoError(t, err)

6180+

require.Len(t, appsA, 1)

6181+

require.Equal(t, appID, appsA[0].ID)

6182+

require.Equal(t, agentA.ID, appsA[0].AgentID)

6183+

require.Equal(t, originalSlug, appsA[0].Slug)

6184+6185+

// Workspace B's agent has no app.

6186+

appsB, err := db.GetWorkspaceAppsByAgentID(ctx, agentB.ID)

6187+

require.NoError(t, err)

6188+

require.Empty(t, appsB)

6189+6190+

// A legitimate rebuild of workspace A produces a new agent (agent IDs are

6191+

// regenerated every build). Rebinding the persistent app to it succeeds

6192+

// because both agents resolve to workspace A.

6193+

agentA2 := addAgent(t, workspaceA, versionA, 2)

6194+

app, err = upsertApp(appID, agentA2.ID, "code-server-v2")

6195+

require.NoError(t, err)

6196+

require.Equal(t, agentA2.ID, app.AgentID)

6197+

require.Equal(t, "code-server-v2", app.Slug)

6198+6199+

appsA2, err := db.GetWorkspaceAppsByAgentID(ctx, agentA2.ID)

6200+

require.NoError(t, err)

6201+

require.Len(t, appsA2, 1)

6202+

require.Equal(t, appID, appsA2[0].ID)

6203+6204+

// Set up a template-import agent. It is intentionally not associated with

6205+

// a workspace build, so it resolves to no workspace.

6206+

importJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{

6207+

Type: database.ProvisionerJobTypeTemplateVersionImport,

6208+

OrganizationID: org.ID,

6209+

})

6210+

importResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{

6211+

JobID: importJob.ID,

6212+

})

6213+

importAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{

6214+

ResourceID: importResource.ID,

6215+

})

6216+

_, err = db.GetWorkspaceByAgentID(ctx, importAgent.ID)

6217+

require.ErrorIs(t, err, sql.ErrNoRows, "import agent must not resolve to a workspace")

6218+6219+

// An app that already belongs to a workspace cannot be rebound to a

6220+

// template-import agent. Otherwise a second update could move it from

6221+

// the import agent to a different workspace.

6222+

_, err = upsertApp(appID, importAgent.ID, "hijacked-by-import")

6223+

require.ErrorIs(t, err, sql.ErrNoRows)

6224+6225+

appsA2, err = db.GetWorkspaceAppsByAgentID(ctx, agentA2.ID)

6226+

require.NoError(t, err)

6227+

require.Len(t, appsA2, 1)

6228+

require.Equal(t, appID, appsA2[0].ID)

6229+

require.Equal(t, agentA2.ID, appsA2[0].AgentID)

6230+

require.Equal(t, "code-server-v2", appsA2[0].Slug)

6231+6232+

appsImport, err := db.GetWorkspaceAppsByAgentID(ctx, importAgent.ID)

6233+

require.NoError(t, err)

6234+

require.Empty(t, appsImport)

6235+6236+

_, err = upsertApp(appID, agentB.ID, "hijacked-after-import")

6237+

require.ErrorIs(t, err, sql.ErrNoRows)

6238+6239+

unownedAppID := uuid.New()

6240+

_, err = upsertApp(unownedAppID, importAgent.ID, "import-app")

6241+

require.NoError(t, err)

6242+6243+

// An app whose existing agent belongs to a template-import job resolves to

6244+

// no workspace, so rebinding it is permitted. It is not a cross-tenant

6245+

// victim.

6246+

rebound, err := upsertApp(unownedAppID, agentA.ID, "import-app")

6247+

require.NoError(t, err)

6248+

require.Equal(t, agentA.ID, rebound.AgentID)

6249+

}

6250+60796251

func TestGetWorkspaceAgentsByParentID(t *testing.T) {

60806252

t.Parallel()

60816253