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+60796251func TestGetWorkspaceAgentsByParentID(t *testing.T) {
60806252t.Parallel()
60816253