Files
coder/coderd/workspaceresourceauth_test.go
T
Michael Suchacz 96333acda3 fix(coderd): filter build instance agents in SQL (#25031)
Replaces the per-agent Go-side template-version filter in
`handleAuthInstanceID` with a purpose-built SQL query.

`GetWorkspaceBuildAgentsByInstanceID` joins `workspace_agents ->
workspace_resources -> workspace_builds -> provisioner_jobs ->
workspaces` and excludes:

- non-`workspace_build` provisioner jobs (template-version-import,
dry-run)
- deleted agents and sub-agents
- deleted workspaces

The handler:

- drops the per-candidate `GetWorkspaceResourceByID` /
`GetProvisionerJobByID` lookups
- drops the `provisioner_jobs.input` JSON parsing and the follow-up
`GetWorkspaceBuildByID` call
- compares `latestHistory.ID` against `selected.WorkspaceBuildID`
returned directly from the query
- preserves the existing recycled-instance safety check and matching
response codes

One intentional behavior tightening: agents whose workspace is deleted
now return 404 (previously they could reach the recycled-instance check
and return 400, or 200 if the stale build was still latest). This
matches the existing token-auth path, which already refuses to
authenticate against deleted workspaces.

The original `GetWorkspaceAgentsByInstanceID` query is intentionally
untouched. It remains the generic raw lookup used elsewhere in tests and
helpers.

The dbauthz wrapper for the new query uses the system-read fast path
with `fetchWithPostFilter` for non-system reads, with `RBACObject()`
delegating to the embedded `WorkspaceTable`.

Tests:

- new `TestGetWorkspaceBuildAgentsByInstanceID` covering newest-first
ordering, exclusion of deleted/sub agents, exclusion of template-import
and dry-run jobs, and exclusion of deleted workspaces
- new dbauthz mock test for `GetWorkspaceBuildAgentsByInstanceID`
- new `TestPostWorkspaceAuthAWSInstanceIdentity/RecycledInstanceID`
exercising the recycled-instance rejection branch (HTTP 400 when the
agent's build is no longer latest)
- existing `TestPostWorkspaceAuth{AWS,Azure,Google}InstanceIdentity`
continue to cover the handler end to end (including the template-version
+ workspace-build same-instance-ID scenario via
`setupInstanceIDWorkspace`)

> Mux is acting on Mike's behalf.
2026-05-12 14:55:56 +02:00

508 lines
16 KiB
Go

package coderd_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"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/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
func TestPostWorkspaceAuthAzureInstanceIdentity(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
AzureCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "dev"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAzureInstanceIdentity())
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
})
t.Run("Ambiguous/AzureWithSelector", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAzureInstanceIdentity(t, instanceID)
client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{
AzureCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
expectedAgent := requireWorkspaceAgentByInstanceIDAndName(t, store, instanceID, "alpha")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAzureInstanceIdentity(
agentsdk.WithInstanceIdentityAgentName("alpha"),
))
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
require.Equal(t, expectedAgent.AuthToken.String(), agentClient.SDK.SessionToken())
})
}
func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) {
t.Parallel()
t.Run("Ambiguous/SingleAgent", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "dev"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAWSInstanceIdentity())
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
})
t.Run("RecycledInstanceID", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
setup := setupInstanceIDWorkspaceWithResources(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "dev"))
successorVersion := coderdtest.CreateTemplateVersion(t, setup.client, setup.user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionGraph: []*proto.Response{{
Type: &proto.Response_Graph{
Graph: &proto.GraphComplete{
Resources: []*proto.Resource{{
Name: "resource",
Type: "instance",
Agents: workspaceAgentsForInstanceID(newTestInstanceID(t), "dev"),
}},
},
},
}},
}, func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = setup.template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, setup.client, successorVersion.ID)
build := coderdtest.CreateWorkspaceBuild(t, setup.client, setup.workspace, database.WorkspaceTransitionStart, func(req *codersdk.CreateWorkspaceBuildRequest) {
req.TemplateVersionID = successorVersion.ID
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, build.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(setup.client.URL, agentsdk.WithAWSInstanceIdentity())
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "isn't registered on the latest history")
})
t.Run("Ambiguous/MultipleAgentsNoSelector", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAWSInstanceIdentity())
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "CODER_AGENT_NAME")
require.Contains(t, apiErr.Message, "alpha, beta")
})
t.Run("Ambiguous/EmptyAgentNameTreatedAsUnset", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
res := postAWSInstanceIdentity(ctx, t, client, metadataClient, "")
defer res.Body.Close()
require.Equal(t, http.StatusConflict, res.StatusCode)
err := codersdk.ReadBodyAsError(res)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "CODER_AGENT_NAME")
require.Contains(t, apiErr.Message, "alpha, beta")
})
t.Run("Ambiguous/WhitespaceAgentNameTreatedAsUnset", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
res := postAWSInstanceIdentity(ctx, t, client, metadataClient, " ")
defer res.Body.Close()
require.Equal(t, http.StatusConflict, res.StatusCode)
err := codersdk.ReadBodyAsError(res)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "CODER_AGENT_NAME")
require.Contains(t, apiErr.Message, "alpha, beta")
})
t.Run("Ambiguous/MultipleAgentsWithSelector", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
expectedAgent := requireWorkspaceAgentByInstanceIDAndName(t, store, instanceID, "alpha")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAWSInstanceIdentity(
agentsdk.WithInstanceIdentityAgentName("alpha"),
))
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
require.Equal(t, expectedAgent.AuthToken.String(), agentClient.SDK.SessionToken())
})
t.Run("Ambiguous/MultipleAgentsUnknownSelector", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAWSInstanceIdentity(
agentsdk.WithInstanceIdentityAgentName("nonexistent"),
))
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Ambiguous/SubAgentExcluded", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{
AWSCertificates: certificates,
}, workspaceAgentsForInstanceID(instanceID, "dev"))
rootAgent := requireWorkspaceAgentByInstanceIDAndName(t, store, instanceID, "dev")
_ = dbgen.WorkspaceSubAgent(t, store, rootAgent, database.WorkspaceAgent{
Name: "sub",
AuthInstanceID: sql.NullString{
String: instanceID,
Valid: true,
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithAWSInstanceIdentity())
agentClient.SDK.HTTPClient = metadataClient
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
require.Equal(t, rootAgent.AuthToken.String(), agentClient.SDK.SessionToken())
})
}
func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
t.Parallel()
t.Run("Expired", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata))
err := agentClient.RefreshToken(ctx)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
})
t.Run("InstanceNotFound", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
client := coderdtest.New(t, &coderdtest.Options{
GoogleTokenValidator: validator,
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata))
err := agentClient.RefreshToken(ctx)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{
GoogleTokenValidator: validator,
}, workspaceAgentsForInstanceID(instanceID, "dev"))
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata))
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
})
t.Run("Ambiguous/GoogleWithSelector", func(t *testing.T) {
t.Parallel()
instanceID := newTestInstanceID(t)
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{
GoogleTokenValidator: validator,
}, workspaceAgentsForInstanceID(instanceID, "alpha", "beta"))
expectedAgent := requireWorkspaceAgentByInstanceIDAndName(t, store, instanceID, "alpha")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity(
"",
metadata,
agentsdk.WithInstanceIdentityAgentName("alpha"),
))
err := agentClient.RefreshToken(ctx)
require.NoError(t, err)
require.Equal(t, expectedAgent.AuthToken.String(), agentClient.SDK.SessionToken())
})
}
type instanceIDWorkspaceSetup struct {
client *codersdk.Client
store database.Store
user codersdk.CreateFirstUserResponse
template codersdk.Template
workspace codersdk.Workspace
}
func setupInstanceIDWorkspace(t *testing.T, opts *coderdtest.Options, agents []*proto.Agent) (*codersdk.Client, database.Store) {
t.Helper()
setup := setupInstanceIDWorkspaceWithResources(t, opts, agents)
return setup.client, setup.store
}
func setupInstanceIDWorkspaceWithResources(
t *testing.T,
opts *coderdtest.Options,
agents []*proto.Agent,
) instanceIDWorkspaceSetup {
t.Helper()
actualOpts := &coderdtest.Options{}
if opts != nil {
*actualOpts = *opts
}
actualOpts.IncludeProvisionerDaemon = true
client, store := coderdtest.NewWithDatabase(t, actualOpts)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionGraph: []*proto.Response{{
Type: &proto.Response_Graph{
Graph: &proto.GraphComplete{
Resources: []*proto.Resource{{
Name: "resource",
Type: "instance",
Agents: agents,
}},
},
},
}},
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
return instanceIDWorkspaceSetup{
client: client,
store: store,
user: user,
template: template,
workspace: workspace,
}
}
func workspaceAgentsForInstanceID(instanceID string, names ...string) []*proto.Agent {
agents := make([]*proto.Agent, 0, len(names))
for _, name := range names {
agents = append(agents, &proto.Agent{
Name: name,
Auth: &proto.Agent_InstanceId{InstanceId: instanceID},
})
}
return agents
}
func requireWorkspaceAgentByInstanceIDAndName(t testing.TB, store database.Store, instanceID string, name string) database.WorkspaceAgent {
t.Helper()
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong))
agents, err := store.GetWorkspaceAgentsByInstanceID(ctx, instanceID)
require.NoError(t, err)
for _, agent := range agents {
if agent.Name == name {
return agent
}
}
require.FailNow(t, "workspace agent not found", "instance ID %q, name %q", instanceID, name)
return database.WorkspaceAgent{}
}
const awsInstanceIdentityMetadataURL = "http://169.254.169.254/latest/dynamic/instance-identity"
func postAWSInstanceIdentity(
ctx context.Context,
t testing.TB,
client *codersdk.Client,
metadataClient *http.Client,
agentName string,
) *http.Response {
t.Helper()
signature := readAWSInstanceMetadata(ctx, t, metadataClient, "signature")
document := readAWSInstanceMetadata(ctx, t, metadataClient, "document")
reqBody, err := json.Marshal(map[string]string{
"signature": signature,
"document": document,
"agent_name": agentName,
})
require.NoError(t, err)
res, err := client.RequestWithoutSessionToken(
ctx,
http.MethodPost,
"/api/v2/workspaceagents/aws-instance-identity",
reqBody,
)
require.NoError(t, err)
return res
}
func readAWSInstanceMetadata(
ctx context.Context,
t testing.TB,
metadataClient *http.Client,
path string,
) string {
t.Helper()
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
awsInstanceIdentityMetadataURL+"/"+path,
nil,
)
require.NoError(t, err)
res, err := metadataClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return string(body)
}
func newTestInstanceID(t testing.TB) string {
t.Helper()
return fmt.Sprintf("instance-%d", time.Now().UnixNano())
}