feat: refresh dynamic parameters on secret changes (#24786)

Publishes user secret create, update, and delete events and subscribes
dynamic parameter websockets to authorized owner secret changes.

Secret changes trigger fresh renders with monotonic response IDs, with
backend tests covering subscription authorization and websocket refresh
behavior.
This commit is contained in:
dylanhuff-at-coder
2026-05-06 09:27:24 -07:00
committed by GitHub
parent 6b0518d051
commit 6a200a49d3
10 changed files with 586 additions and 58 deletions
+189 -8
View File
@@ -414,6 +414,183 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
}}, preview.SecretRequirements)
})
t.Run("SecretRequirementPushesOnSecretChange", func(t *testing.T) {
t.Parallel()
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf")
require.NoError(t, err)
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
})
ctx := testutil.Context(t, testutil.WaitMedium)
previews := setup.stream.Chan()
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.False(t, preview.SecretRequirements[0].Satisfied)
_, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_test",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 0, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.True(t, preview.SecretRequirements[0].Satisfied)
err = setup.dynamicParamsClient.DeleteUserSecret(ctx, codersdk.Me, "github-token")
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 1, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.False(t, preview.SecretRequirements[0].Satisfied)
_, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_test",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 2, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.True(t, preview.SecretRequirements[0].Satisfied)
otherEnvName := "OTHER_GITHUB_TOKEN"
_, err = setup.dynamicParamsClient.UpdateUserSecret(ctx, codersdk.Me, "github-token", codersdk.UpdateUserSecretRequest{
EnvName: &otherEnvName,
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 3, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.False(t, preview.SecretRequirements[0].Satisfied)
})
t.Run("SecretRequirementPushesAfterOwnerSwitch", func(t *testing.T) {
t.Parallel()
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf")
require.NoError(t, err)
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
// No production role grants cross-user user_secret:read today,
// so use an allow-all authorizer for lifecycle coverage.
authorizer: &coderdtest.FakeAuthorizer{},
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
})
ctx := testutil.Context(t, testutil.WaitMedium)
previews := setup.stream.Chan()
targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID)
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.False(t, preview.SecretRequirements[0].Satisfied)
err = setup.stream.Send(codersdk.DynamicParametersRequest{
ID: 0,
Inputs: map[string]string{},
OwnerID: target.ID,
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 0, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.False(t, preview.SecretRequirements[0].Satisfied)
_, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_target",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 1, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.True(t, preview.SecretRequirements[0].Satisfied)
_, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_initial",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
require.Never(t, func() bool {
select {
case <-previews:
return true
default:
return false
}
}, testutil.WaitShort/5, testutil.IntervalFast)
})
t.Run("SecretRequirementDoesNotSubscribeWhenOwnerUnauthorized", func(t *testing.T) {
t.Parallel()
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf")
require.NoError(t, err)
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
})
ctx := testutil.Context(t, testutil.WaitMedium)
previews := setup.stream.Chan()
targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID)
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
require.Len(t, preview.SecretRequirements, 1)
require.False(t, preview.SecretRequirements[0].Satisfied)
err = setup.stream.Send(codersdk.DynamicParametersRequest{
ID: 0,
Inputs: map[string]string{},
OwnerID: target.ID,
})
require.NoError(t, err)
preview = testutil.RequireReceive(ctx, t, previews)
require.Equal(t, 0, preview.ID)
require.Empty(t, preview.SecretRequirements)
require.Len(t, preview.Diagnostics, 1)
require.Equal(t, dynamicparameters.DiagCodeSecretValidationForbidden, preview.Diagnostics[0].Extra.Code)
_, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_target",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
require.Never(t, func() bool {
select {
case <-previews:
return true
default:
return false
}
}, testutil.WaitShort/5, testutil.IntervalFast)
})
// Regression test for PLAT-100: a workspace whose template has an
// unsatisfied coder_secret requirement must still be stoppable and
// deletable. Start remains blocked.
@@ -475,6 +652,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
type setupDynamicParamsTestParams struct {
db database.Store
ps pubsub.Pubsub
authorizer rbac.Authorizer
provisionerDaemonVersion string
mainTF []byte
modulesArchive []byte
@@ -486,16 +664,18 @@ type setupDynamicParamsTestParams struct {
}
type dynamicParamsTest struct {
client *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
template codersdk.Template
client *codersdk.Client
dynamicParamsClient *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
template codersdk.Template
}
func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest {
ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
Database: args.db,
Pubsub: args.ps,
Authorizer: args.authorizer,
IncludeProvisionerDaemon: true,
ProvisionerDaemonVersion: args.provisionerDaemonVersion,
})
@@ -530,10 +710,11 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
})
return dynamicParamsTest{
client: ownerClient,
api: api,
stream: stream,
template: tpl,
client: ownerClient,
dynamicParamsClient: templateAdmin,
api: api,
stream: stream,
template: tpl,
}
}