chore: remove coder_secret Terraform integration (#25512)

Removes the coder_secret Terraform integration: the data.coder_secret
consumption path through provisionerdserver → provisioner.proto →
provisioner/terraform, the dynamic-parameter secret-requirement
validation, and the workspace-update / resolve-autostart surfaces that
depended on it. This is being done due to a product/feature direction
change (see PLAT-243). User-secret CRUD (DB, REST, CLI, UI, telemetry, audit)
and the agent-manifest secret-injection path are untouched.

The provisionerd API is bumped from v1.17 to v1.18 rather than rolled
back: v1.17 shipped in v2.33.x, so user_secrets field numbers are
reserved and the changelog documents both versions.

Generated with assistance from Coder Agents.
This commit is contained in:
Zach
2026-05-21 09:19:29 -06:00
committed by GitHub
parent 26a0805dcd
commit ddc0e99c69
45 changed files with 835 additions and 3859 deletions
+1 -28
View File
@@ -18895,7 +18895,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"id": {
"description": "ID identifies the request for response ordering. Websocket response\nIDs are monotonically increasing and may exceed the request ID when\nserver-side events trigger additional renders.",
"description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.",
"type": "integer"
},
"inputs": {
@@ -18928,12 +18928,6 @@ const docTemplate = `{
"items": {
"$ref": "#/definitions/codersdk.PreviewParameter"
}
},
"secret_requirements": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.SecretRequirementStatus"
}
}
}
},
@@ -22554,10 +22548,6 @@ const docTemplate = `{
"properties": {
"parameter_mismatch": {
"type": "boolean"
},
"secret_mismatch": {
"description": "SecretMismatch is true when the active template version declares\n` + "`" + `coder_secret` + "`" + ` requirements that the workspace owner's secrets do not\nsatisfy.",
"type": "boolean"
}
}
},
@@ -22778,23 +22768,6 @@ const docTemplate = `{
}
}
},
"codersdk.SecretRequirementStatus": {
"type": "object",
"properties": {
"env": {
"type": "string"
},
"file": {
"type": "string"
},
"help_message": {
"type": "string"
},
"satisfied": {
"type": "boolean"
}
}
},
"codersdk.ServerSentEvent": {
"type": "object",
"properties": {
+1 -28
View File
@@ -17163,7 +17163,7 @@
"type": "object",
"properties": {
"id": {
"description": "ID identifies the request for response ordering. Websocket response\nIDs are monotonically increasing and may exceed the request ID when\nserver-side events trigger additional renders.",
"description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.",
"type": "integer"
},
"inputs": {
@@ -17196,12 +17196,6 @@
"items": {
"$ref": "#/definitions/codersdk.PreviewParameter"
}
},
"secret_requirements": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.SecretRequirementStatus"
}
}
}
},
@@ -20687,10 +20681,6 @@
"properties": {
"parameter_mismatch": {
"type": "boolean"
},
"secret_mismatch": {
"description": "SecretMismatch is true when the active template version declares\n`coder_secret` requirements that the workspace owner's secrets do not\nsatisfy.",
"type": "boolean"
}
}
},
@@ -20911,23 +20901,6 @@
}
}
},
"codersdk.SecretRequirementStatus": {
"type": "object",
"properties": {
"env": {
"type": "string"
},
"file": {
"type": "string"
},
"help_message": {
"type": "string"
},
"satisfied": {
"type": "boolean"
}
}
},
"codersdk.ServerSentEvent": {
"type": "object",
"properties": {
-1
View File
@@ -343,7 +343,6 @@ func (e *Executor) runOnce(t time.Time) Stats {
SetLastWorkspaceBuildJobInTx(&latestJob).
Experiments(e.experiments).
Reason(reason).
Logger(log.Named("wsbuilder")).
BuildMetrics(e.workspaceBuilderMetrics)
log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition))
if nextTransition == database.WorkspaceTransitionStart &&
-2
View File
@@ -246,7 +246,6 @@ var (
rbac.ResourceWorkspaceAgentDevcontainers.Type: {policy.ActionCreate},
// Provisionerd creates usage events
rbac.ResourceUsageEvent.Type: {policy.ActionCreate},
rbac.ResourceUserSecret.Type: {policy.ActionRead},
}),
User: []rbac.Permission{},
ByOrgID: map[string]rbac.OrgPermissions{},
@@ -271,7 +270,6 @@ var (
rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate},
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
rbac.ResourceUser.Type: {policy.ActionRead},
rbac.ResourceUserSecret.Type: {policy.ActionRead},
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
}),
-13
View File
@@ -6841,19 +6841,6 @@ func TestAuthorizeProvisionerJob_SystemFastPath(t *testing.T) {
})
}
func TestAsAutostart(t *testing.T) {
t.Parallel()
ctx := dbauthz.AsAutostart(context.Background())
actor, ok := dbauthz.ActorFromContext(ctx)
require.True(t, ok, "actor must be present")
auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceUserSecret.WithOwner(uuid.NewString()))
require.NoError(t, err, "user secret metadata read should be allowed")
}
func TestAsChatd(t *testing.T) {
t.Parallel()
+16 -196
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"io/fs"
"log/slog"
"sync"
"time"
@@ -12,27 +13,14 @@ import (
"github.com/zclconf/go-cty/cty"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/apiversion"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
)
// RenderResult is the structured output of Renderer.Render. The outer
// pointer is always non-nil; inner fields may be nil.
// SecretRequirements is nil when no coder_secret blocks are declared,
// when fetch was forbidden, or when fetch failed. Output may be nil
// when underlying rendering fails (matches preview.Preview's existing
// convention).
type RenderResult struct {
Output *preview.Output
SecretRequirements []codersdk.SecretRequirementStatus
}
// Renderer is able to execute and evaluate terraform with the given inputs.
// It may use the database to fetch additional state, such as a user's groups,
// roles, etc. Therefore, it requires an authenticated `ctx`.
@@ -40,40 +28,17 @@ type RenderResult struct {
// 'Close()' **must** be called once the renderer is no longer needed.
// Forgetting to do so will result in a memory leak.
type Renderer interface {
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...RenderOption) (*RenderResult, hcl.Diagnostics)
Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics)
Close()
}
var ErrTemplateVersionNotReady = xerrors.New("template version job not finished")
// RenderOption configures optional behavior for Renderer.Render.
type RenderOption func(*renderOptions)
type renderOptions struct {
includeSecretRequirements bool
}
// IncludeSecretRequirements returns structured secret-requirement statuses and
// diagnostics for the rendered template.
func IncludeSecretRequirements() RenderOption {
return func(o *renderOptions) {
o.includeSecretRequirements = true
}
}
// Diagnostic extra codes for secret-requirement validation.
const (
DiagCodeMissingSecret = "missing_secret"
DiagCodeOwnerSecretsFetchFailed = "owner_secrets_fetch_failed"
DiagCodeSecretValidationForbidden = "secret_validation_forbidden"
)
// loader is used to load the necessary coder objects for rendering a template
// version's parameters. The output is a Renderer, which is the object that uses
// the cached objects to render the template version's parameters.
type loader struct {
templateVersionID uuid.UUID
logger slog.Logger
// cache of objects
templateVersion *database.TemplateVersion
@@ -125,13 +90,6 @@ func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *
}
}
// WithLogger sets the logger used by the renderer.
func WithLogger(logger slog.Logger) func(r *loader) {
return func(r *loader) {
r.logger = logger
}
}
func (r *loader) loadData(ctx context.Context, db database.Store) error {
if r.templateVersion == nil {
tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID)
@@ -245,14 +203,12 @@ func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *
closeFiles = false // Caller will have to call close
return &dynamicRenderer{
data: r,
templateFS: templateFS,
db: db,
logger: r.logger,
ownerErrors: make(map[uuid.UUID]error),
ownerSecretErrors: make(map[uuid.UUID]error),
close: cache.Close,
tfvarValues: tfVarValues,
data: r,
templateFS: templateFS,
db: db,
ownerErrors: make(map[uuid.UUID]error),
close: cache.Close,
tfvarValues: tfVarValues,
}, nil
}
@@ -260,26 +216,16 @@ type dynamicRenderer struct {
db database.Store
data *loader
templateFS fs.FS
logger slog.Logger
ownerErrors map[uuid.UUID]error
currentOwner *previewtypes.WorkspaceOwner
// ownerSecretErrors caches NotAuthorized denials per owner.
ownerSecretErrors map[uuid.UUID]error
tfvarValues map[string]cty.Value
tfvarValues map[string]cty.Value
once sync.Once
close func()
}
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...RenderOption) (*RenderResult, hcl.Diagnostics) {
options := renderOptions{}
for _, opt := range opts {
opt(&options)
}
func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
// Always start with the cached error, if we have one.
ownerErr := r.ownerErrors[ownerID]
if ownerErr == nil {
@@ -288,7 +234,7 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
if ownerErr != nil || r.currentOwner == nil {
r.ownerErrors[ownerID] = ownerErr
return &RenderResult{}, hcl.Diagnostics{
return nil, hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Failed to fetch workspace owner",
@@ -305,122 +251,13 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
ParameterValues: values,
Owner: *r.currentOwner,
TFVars: r.tfvarValues,
// Leave Logger nil so preview discards parser logs. Returning
// those logs to callers would be useful, but they may be large.
// Do not emit parser logs to coderd output logs.
// TODO: Returning this logs in the output would benefit the caller.
// Unsure how large the logs can be, so for now we just discard them.
Logger: slog.New(slog.DiscardHandler),
}
output, diags := preview.Preview(ctx, input, r.templateFS)
if output == nil {
return &RenderResult{}, diags
}
var secretRequirements []codersdk.SecretRequirementStatus
if options.includeSecretRequirements && len(output.SecretRequirements) > 0 {
var secretDiags hcl.Diagnostics
secretRequirements, secretDiags = r.checkSecretRequirements(ctx, ownerID, output.SecretRequirements)
diags = diags.Extend(secretDiags)
}
return &RenderResult{
Output: output,
SecretRequirements: secretRequirements,
}, diags
}
// checkSecretRequirements returns structured requirement statuses. Callers
// without user_secret:read on the owner get a single
// secret_validation_forbidden warning instead, to avoid leaking the target's
// secret names via structured status presence.
func (r *dynamicRenderer) checkSecretRequirements(ctx context.Context, ownerID uuid.UUID, reqs []previewtypes.SecretRequirement) ([]codersdk.SecretRequirementStatus, hcl.Diagnostics) {
secrets, err := r.getOwnerSecrets(ctx, ownerID)
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
// Warning keeps the Create Workspace button enabled.
return nil, hcl.Diagnostics{{
Severity: hcl.DiagWarning,
Summary: "Cannot validate secret requirements",
Detail: "You are not permitted to read secret metadata for this user. The workspace may fail to build if required secrets are not set.",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeSecretValidationForbidden,
},
}}
}
r.logger.Warn(ctx, "failed to fetch owner secrets for secret-requirement validation",
slog.F("owner_id", ownerID),
slog.Error(err),
)
return nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Failed to fetch owner secrets",
Detail: "Could not validate template secret requirements. Please try again.",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeOwnerSecretsFetchFailed,
},
}}
}
envSet := make(map[string]struct{}, len(secrets))
fileSet := make(map[string]struct{}, len(secrets))
for _, s := range secrets {
if s.EnvName != "" {
envSet[s.EnvName] = struct{}{}
}
if s.FilePath != "" {
fileSet[s.FilePath] = struct{}{}
}
}
statuses := make([]codersdk.SecretRequirementStatus, 0, len(reqs))
type secretRequirementDedupKey struct {
env string
file string
}
seen := make(map[secretRequirementDedupKey]int, len(reqs))
for _, req := range reqs {
kind := secretRequirementKind(req.Env, req.File)
if kind == "" {
// Defensive: SecretFromBlock should reject invalid inputs upstream.
continue
}
var env string
var file string
satisfied := false
switch kind {
case secretRequirementKindEnv:
env = req.Env
_, satisfied = envSet[req.Env]
case secretRequirementKindFile:
file = req.File
_, satisfied = fileSet[req.File]
}
// Dedup by Env/File. On collision, keep the
// lexicographically smallest non-empty HelpMessage. This is
// deterministic across runs; preview's SortSecretRequirements
// sorts on (Env, File) and does not guarantee a stable order
// when multiple coder_secret blocks declare the same value, so
// we cannot rely on "first source wins."
key := secretRequirementDedupKey{
env: env,
file: file,
}
if i, ok := seen[key]; ok {
statuses[i].Satisfied = statuses[i].Satisfied || satisfied
if req.HelpMessage != "" && (statuses[i].HelpMessage == "" || req.HelpMessage < statuses[i].HelpMessage) {
statuses[i].HelpMessage = req.HelpMessage
}
continue
}
seen[key] = len(statuses)
statuses = append(statuses, codersdk.SecretRequirementStatus{
Env: env,
File: file,
HelpMessage: req.HelpMessage,
Satisfied: satisfied,
})
}
return statuses, nil
return preview.Preview(ctx, input, r.templateFS)
}
func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
@@ -437,23 +274,6 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
return nil
}
// getOwnerSecrets fetches the owner's secrets under the caller's auth
// context. Only NotAuthorized denials are cached; successes re-fetch so
// newly-created secrets are picked up on the next render.
func (r *dynamicRenderer) getOwnerSecrets(ctx context.Context, ownerID uuid.UUID) ([]database.ListUserSecretsRow, error) {
if err, cached := r.ownerSecretErrors[ownerID]; cached {
return nil, err
}
rows, err := r.db.ListUserSecrets(ctx, ownerID)
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
r.ownerSecretErrors[ownerID] = err
}
return nil, err
}
return rows, nil
}
func (r *dynamicRenderer) Close() {
r.once.Do(r.close)
}
@@ -1,394 +0,0 @@
package dynamicparameters
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"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/dbtestutil"
"github.com/coder/coder/v2/codersdk"
previewtypes "github.com/coder/preview/types"
)
// newTestRenderer builds a dynamicRenderer backed by the given testdata
// fixture. The caller must seed an org and member row.
func newTestRenderer(t *testing.T, db database.Store, orgID uuid.UUID, fixture string) *dynamicRenderer {
t.Helper()
return &dynamicRenderer{
db: db,
templateFS: os.DirFS(filepath.Join("testdata", fixture)),
ownerErrors: make(map[uuid.UUID]error),
ownerSecretErrors: make(map[uuid.UUID]error),
data: &loader{
templateVersion: &database.TemplateVersion{
OrganizationID: orgID,
},
terraformValues: &database.TemplateVersionTerraformValue{},
},
close: func() {},
}
}
// seedOwner creates a user and org member so WorkspaceOwner resolves.
func seedOwner(t *testing.T, db database.Store, orgID uuid.UUID) database.User {
t.Helper()
u := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: orgID,
UserID: u.ID,
})
return u
}
func TestDynamicRender_MissingSecretRequirement(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
renderer := newTestRenderer(t, db, org.ID, "secret_required")
defer renderer.Close()
// Owner has no secrets; the GITHUB_TOKEN requirement is unmet.
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
require.NotNil(t, out)
require.NotNil(t, out.Output)
requireNoMissingSecret(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
Satisfied: false,
}}, out.SecretRequirements)
// The same renderer must pick up a newly-created secret on the
// next render, without a reload.
_ = dbgen.UserSecret(t, db, database.UserSecret{
UserID: owner.ID,
Name: "github_token",
EnvName: "GITHUB_TOKEN",
})
out, diags2 := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
requireNoMissingSecret(t, diags2)
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
Satisfied: true,
}}, out.SecretRequirements)
}
func TestDynamicRender_ConditionalSecretRequirement(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
renderer := newTestRenderer(t, db, org.ID, "secret_conditional")
defer renderer.Close()
// Block inactive: no validation.
out, diags := renderer.Render(ctx, owner.ID, map[string]string{"use_github": "false"}, IncludeSecretRequirements())
requireNoMissingSecret(t, diags)
require.Nil(t, out.SecretRequirements)
// Block active: requirement surfaces.
out, diags = renderer.Render(ctx, owner.ID, map[string]string{"use_github": "true"}, IncludeSecretRequirements())
requireNoMissingSecret(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT",
Satisfied: false,
}}, out.SecretRequirements)
}
func TestDynamicRender_SingleSecretSatisfiesEnvAndFile(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
// One row must satisfy both an env and a file requirement: the
// check builds independent envSet and fileSet maps.
_ = dbgen.UserSecret(t, db, database.UserSecret{
UserID: owner.ID,
Name: "combined",
EnvName: "GITHUB_TOKEN",
FilePath: "~/.ssh/id_rsa",
})
renderer := newTestRenderer(t, db, org.ID, "secret_env_and_file")
defer renderer.Close()
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
requireNoMissingSecret(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{
{
File: "~/.ssh/id_rsa",
HelpMessage: "needs file",
Satisfied: true,
},
{
Env: "GITHUB_TOKEN",
HelpMessage: "needs env",
Satisfied: true,
},
}, out.SecretRequirements)
}
func TestDynamicRender_PartialEnvAndFileSatisfaction(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
// Env-only secret against an env+file requirement: only the file
// requirement should fail.
_ = dbgen.UserSecret(t, db, database.UserSecret{
UserID: owner.ID,
Name: "env_only",
EnvName: "GITHUB_TOKEN",
})
renderer := newTestRenderer(t, db, org.ID, "secret_env_and_file")
defer renderer.Close()
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
requireNoMissingSecret(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{
{
File: "~/.ssh/id_rsa",
HelpMessage: "needs file",
Satisfied: false,
},
{
Env: "GITHUB_TOKEN",
HelpMessage: "needs env",
Satisfied: true,
},
}, out.SecretRequirements)
}
func TestDynamicRender_OwnerSwitch(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
// Owner A satisfies the requirement; owner B does not.
ownerA := seedOwner(t, db, org.ID)
ownerB := seedOwner(t, db, org.ID)
_ = dbgen.UserSecret(t, db, database.UserSecret{
UserID: ownerA.ID,
Name: "gh",
EnvName: "GITHUB_TOKEN",
})
renderer := newTestRenderer(t, db, org.ID, "secret_required")
defer renderer.Close()
out, diags := renderer.Render(ctx, ownerA.ID, nil, IncludeSecretRequirements())
requireNoMissingSecret(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
Satisfied: true,
}}, out.SecretRequirements)
// The cache must not serve owner A's rows to owner B.
out, diags = renderer.Render(ctx, ownerB.ID, nil, IncludeSecretRequirements())
requireNoMissingSecret(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
Satisfied: false,
}}, out.SecretRequirements)
}
func TestDynamicRender_DeduplicatesSecretRequirements(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
renderer := newTestRenderer(t, db, org.ID, "secret_required")
defer renderer.Close()
reqs := []previewtypes.SecretRequirement{
{Env: "GITHUB_TOKEN", HelpMessage: "z help"},
{Env: "GITHUB_TOKEN", HelpMessage: "a help"},
}
statuses, diags := renderer.checkSecretRequirements(ctx, owner.ID, reqs)
require.Empty(t, diags)
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "a help",
Satisfied: false,
}}, statuses)
}
// countingStore counts ListUserSecrets calls per user.
type countingStore struct {
database.Store
mu sync.Mutex
calls map[uuid.UUID]int
}
func (c *countingStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) {
c.mu.Lock()
if c.calls == nil {
c.calls = map[uuid.UUID]int{}
}
c.calls[userID]++
c.mu.Unlock()
return c.Store.ListUserSecrets(ctx, userID)
}
func (c *countingStore) callsFor(id uuid.UUID) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls[id]
}
// TestDynamicRender_NotAuthorizedIsCached pins that NotAuthorized
// denials hit ListUserSecrets at most once per owner.
func TestDynamicRender_NotAuthorizedIsCached(t *testing.T) {
t.Parallel()
inner, _ := dbtestutil.NewDB(t)
db := &countingStore{Store: secretAuthDenyingStore{Store: inner}}
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
renderer := newTestRenderer(t, db, org.ID, "secret_required")
defer renderer.Close()
for range 3 {
_, _ = renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
}
require.Equal(t, 1, db.callsFor(owner.ID),
"NotAuthorized must be cached across renders")
}
// secretAuthDenyingStore makes ListUserSecrets return NotAuthorized,
// simulating a non-owner caller.
type secretAuthDenyingStore struct {
database.Store
}
func (secretAuthDenyingStore) ListUserSecrets(_ context.Context, _ uuid.UUID) ([]database.ListUserSecretsRow, error) {
return nil, dbauthz.NotAuthorizedError{}
}
type secretFetchFailingStore struct {
database.Store
}
func (secretFetchFailingStore) ListUserSecrets(_ context.Context, _ uuid.UUID) ([]database.ListUserSecretsRow, error) {
return nil, xerrors.New("fetch failed")
}
func TestDynamicRender_SecretFetchFailedHasNilRequirements(t *testing.T) {
t.Parallel()
inner, _ := dbtestutil.NewDB(t)
db := secretFetchFailingStore{Store: inner}
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
renderer := newTestRenderer(t, db, org.ID, "secret_required")
defer renderer.Close()
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
require.Nil(t, out.SecretRequirements)
requireNoMissingSecret(t, diags)
var sawErr bool
for _, d := range diags {
extra, ok := d.Extra.(previewtypes.DiagnosticExtra)
if !ok {
continue
}
if extra.Code == DiagCodeOwnerSecretsFetchFailed {
require.Equal(t, hcl.DiagError, d.Severity)
sawErr = true
}
}
require.True(t, sawErr, "expected owner_secrets_fetch_failed error")
}
// TestDynamicRender_NonOwnerCannotLeakSecretRequirements guards against
// a non-owner enumerating secret names via missing_secret diagnostics.
func TestDynamicRender_NonOwnerCannotLeakSecretRequirements(t *testing.T) {
t.Parallel()
inner, _ := dbtestutil.NewDB(t)
db := secretAuthDenyingStore{Store: inner}
ctx := t.Context()
org := dbgen.Organization(t, db, database.Organization{})
owner := seedOwner(t, db, org.ID)
// Secret matches the requirement; a non-owner must still never
// see it.
_ = dbgen.UserSecret(t, db, database.UserSecret{
UserID: owner.ID,
Name: "gh",
EnvName: "GITHUB_TOKEN",
})
renderer := newTestRenderer(t, db, org.ID, "secret_required")
defer renderer.Close()
out, diags := renderer.Render(ctx, owner.ID, nil, IncludeSecretRequirements())
require.Nil(t, out.SecretRequirements)
// No missing_secret diagnostic for a non-owner, regardless of
// whether the target satisfies the requirement.
requireNoMissingSecret(t, diags)
// Surface a warning so the admin knows validation didn't run.
var sawWarn bool
for _, d := range diags {
extra, ok := d.Extra.(previewtypes.DiagnosticExtra)
if !ok {
continue
}
if extra.Code == DiagCodeSecretValidationForbidden {
require.Equal(t, hcl.DiagWarning, d.Severity,
"secret_validation_forbidden must be a warning")
sawWarn = true
}
}
require.True(t, sawWarn, "expected secret_validation_forbidden warning")
}
func requireNoMissingSecret(t *testing.T, diags hcl.Diagnostics) {
t.Helper()
for _, d := range diags {
if extra, ok := d.Extra.(previewtypes.DiagnosticExtra); ok && extra.Code == DiagCodeMissingSecret {
t.Fatalf("unexpected missing_secret diagnostic: %s", d.Detail)
}
}
}
@@ -13,7 +13,7 @@ import (
context "context"
reflect "reflect"
dynamicparameters "github.com/coder/coder/v2/coderd/dynamicparameters"
preview "github.com/coder/preview"
uuid "github.com/google/uuid"
hcl "github.com/hashicorp/hcl/v2"
gomock "go.uber.org/mock/gomock"
@@ -56,21 +56,16 @@ func (mr *MockRendererMockRecorder) Close() *gomock.Call {
}
// Render mocks base method.
func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...dynamicparameters.RenderOption) (*dynamicparameters.RenderResult, hcl.Diagnostics) {
func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
m.ctrl.T.Helper()
varargs := []any{ctx, ownerID, values}
for _, a := range opts {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Render", varargs...)
ret0, _ := ret[0].(*dynamicparameters.RenderResult)
ret := m.ctrl.Call(m, "Render", ctx, ownerID, values)
ret0, _ := ret[0].(*preview.Output)
ret1, _ := ret[1].(hcl.Diagnostics)
return ret0, ret1
}
// Render indicates an expected call of Render.
func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any, opts ...any) *gomock.Call {
func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, ownerID, values}, opts...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), varargs...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), ctx, ownerID, values)
}
+2 -89
View File
@@ -3,7 +3,6 @@ package dynamicparameters
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
@@ -11,7 +10,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
previewtypes "github.com/coder/preview/types"
"github.com/coder/terraform-provider-coder/v2/provider"
)
@@ -24,33 +22,11 @@ const (
sourcePreset
)
const (
secretRequirementKindEnv = "env"
secretRequirementKindFile = "file"
)
type parameterValue struct {
Value string
Source parameterValueSource
}
// ResolveOption configures optional behavior for ResolveParameters.
type ResolveOption func(*resolveOptions)
type resolveOptions struct {
skipSecretRequirements bool
}
// SkipSecretRequirements skips structured secret-requirement validation and
// enforcement. Callers must pass this for non-start transitions so an
// unsatisfied coder_secret, or an admin who can't read the owner's secrets,
// doesn't block stop or delete.
func SkipSecretRequirements() ResolveOption {
return func(o *resolveOptions) {
o.skipSecretRequirements = true
}
}
//nolint:revive // firstbuild is a control flag to turn on immutable validation
func ResolveParameters(
ctx context.Context,
@@ -60,12 +36,7 @@ func ResolveParameters(
previousValues []database.WorkspaceBuildParameter,
buildValues []codersdk.WorkspaceBuildParameter,
presetValues []database.TemplateVersionPresetParameter,
opts ...ResolveOption,
) (map[string]string, error) {
o := resolveOptions{}
for _, opt := range opts {
opt(&o)
}
previousValuesMap := slice.ToMapFunc(previousValues, func(p database.WorkspaceBuildParameter) (string, string) {
return p.Name, p.Value
})
@@ -99,7 +70,7 @@ func ResolveParameters(
//
// This is how the form should look to the user on their workspace settings page.
// This is the original form truth that our validations should initially be based on.
result, diags := renderer.Render(ctx, ownerID, previousValuesMap)
output, diags := renderer.Render(ctx, ownerID, previousValuesMap)
if diags.HasErrors() {
// Top level diagnostics should break the build. Previous values (and new) should
// always be valid. If there is a case where this is not true, then this has to
@@ -107,7 +78,6 @@ func ResolveParameters(
return nil, parameterValidationError(diags)
}
output := result.Output
// The user's input now needs to be validated against the parameters.
// Mutability & Ephemeral parameters depend on sequential workspace builds.
@@ -128,33 +98,10 @@ func ResolveParameters(
// This is the final set of values that will be used. Any errors at this stage
// are fatal. Additional validation for immutability has to be done manually.
var renderOpts []RenderOption
if !o.skipSecretRequirements {
renderOpts = append(renderOpts, IncludeSecretRequirements())
}
result, diags = renderer.Render(ctx, ownerID, values.ValuesMap(), renderOpts...)
if !o.skipSecretRequirements && !diags.HasErrors() {
var missing []codersdk.SecretRequirementStatus
for _, req := range result.SecretRequirements {
if !req.Satisfied {
missing = append(missing, req)
}
}
if len(missing) > 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing required secrets",
Detail: formatMissingSecrets(missing),
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeMissingSecret,
},
})
}
}
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
if diags.HasErrors() {
return nil, parameterValidationError(diags)
}
output = result.Output
// parameterNames is going to be used to remove any excess values left
// around without a parameter.
@@ -281,37 +228,3 @@ func (p parameterValueMap) ValuesMap() map[string]string {
}
return values
}
func secretRequirementKind(env, file string) string {
switch {
case env != "" && file == "":
return secretRequirementKindEnv
case file != "" && env == "":
return secretRequirementKindFile
default:
return ""
}
}
func formatMissingSecrets(reqs []codersdk.SecretRequirementStatus) string {
var b strings.Builder
for i, req := range reqs {
if i > 0 {
_, _ = b.WriteString("\n")
}
switch secretRequirementKind(req.Env, req.File) {
case secretRequirementKindEnv:
_, _ = fmt.Fprintf(&b, "%s %s", secretRequirementKindEnv, req.Env)
case secretRequirementKindFile:
_, _ = fmt.Fprintf(&b, "%s %s", secretRequirementKindFile, req.File)
default:
// checkSecretRequirements filters malformed requirements produced
// by preview before they reach the resolver.
_, _ = b.WriteString("malformed secret requirement")
}
if req.HelpMessage != "" {
_, _ = fmt.Fprintf(&b, ": %s", req.HelpMessage)
}
}
return b.String()
}
@@ -1,68 +0,0 @@
package dynamicparameters
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestFormatMissingSecrets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
reqs []codersdk.SecretRequirementStatus
want string
}{
{
name: "Env",
reqs: []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT",
}},
want: "env GITHUB_TOKEN: Add a GitHub PAT",
},
{
name: "File",
reqs: []codersdk.SecretRequirementStatus{{
File: "~/.ssh/id_rsa",
}},
want: "file ~/.ssh/id_rsa",
},
{
name: "Multiple",
reqs: []codersdk.SecretRequirementStatus{
{
Env: "GITHUB_TOKEN",
},
{
File: "~/.ssh/id_rsa",
HelpMessage: "Add an SSH key",
},
},
want: "env GITHUB_TOKEN\nfile ~/.ssh/id_rsa: Add an SSH key",
},
{
name: "MalformedEmpty",
reqs: []codersdk.SecretRequirementStatus{{}},
want: "malformed secret requirement",
},
{
name: "MalformedBothEnvAndFile",
reqs: []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
File: "~/.ssh/id_rsa",
}},
want: "malformed secret requirement",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, formatMissingSecrets(tt.reqs))
})
}
}
+47 -242
View File
@@ -4,7 +4,6 @@ import (
"testing"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -33,37 +32,23 @@ func TestResolveParameters(t *testing.T) {
render.EXPECT().
Render(gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes().
Return(renderResult(
previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: "immutable",
Type: previewtypes.ParameterTypeString,
FormType: provider.ParameterFormTypeInput,
Mutable: false,
DefaultValue: previewtypes.StringLiteral("foo"),
Required: true,
Return(&preview.Output{
Parameters: []previewtypes.Parameter{
{
ParameterData: previewtypes.ParameterData{
Name: "immutable",
Type: previewtypes.ParameterTypeString,
FormType: provider.ParameterFormTypeInput,
Mutable: false,
DefaultValue: previewtypes.StringLiteral("foo"),
Required: true,
},
Value: previewtypes.StringLiteral("foo"),
Diagnostics: nil,
},
Value: previewtypes.StringLiteral("foo"),
Diagnostics: nil,
},
), nil)
render.EXPECT().
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes().
Return(renderResult(
previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: "immutable",
Type: previewtypes.ParameterTypeString,
FormType: provider.ParameterFormTypeInput,
Mutable: false,
DefaultValue: previewtypes.StringLiteral("foo"),
Required: true,
},
Value: previewtypes.StringLiteral("foo"),
Diagnostics: nil,
},
), nil)
}, nil)
ctx := testutil.Context(t, testutil.WaitShort)
values, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false,
[]database.WorkspaceBuildParameter{}, // No previous values
@@ -96,25 +81,29 @@ func TestResolveParameters(t *testing.T) {
render.EXPECT().
Render(gomock.Any(), gomock.Any(), gomock.Any()).
// Return the mutable param first
Return(renderResult(
previewtypes.Parameter{
ParameterData: mutable,
Value: previewtypes.StringLiteral("foo"),
Diagnostics: nil,
Return(&preview.Output{
Parameters: []previewtypes.Parameter{
{
ParameterData: mutable,
Value: previewtypes.StringLiteral("foo"),
Diagnostics: nil,
},
},
), nil)
}, nil)
render.EXPECT().
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Render(gomock.Any(), gomock.Any(), gomock.Any()).
// Then the immutable param
Return(renderResult(
previewtypes.Parameter{
ParameterData: immutable,
// The user set the value to bar
Value: previewtypes.StringLiteral("bar"),
Diagnostics: nil,
Return(&preview.Output{
Parameters: []previewtypes.Parameter{
{
ParameterData: immutable,
// The user set the value to bar
Value: previewtypes.StringLiteral("bar"),
Diagnostics: nil,
},
},
), nil)
}, nil)
ctx := testutil.Context(t, testutil.WaitShort)
_, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false,
@@ -170,39 +159,23 @@ func TestResolveParameters(t *testing.T) {
render.EXPECT().
Render(gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes().
Return(renderResult(
previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: "param",
Type: previewtypes.ParameterTypeNumber,
FormType: provider.ParameterFormTypeInput,
Mutable: true,
Validations: []*previewtypes.ParameterValidation{
{Monotonic: ptr.Ref(tc.monotonic)},
Return(&preview.Output{
Parameters: []previewtypes.Parameter{
{
ParameterData: previewtypes.ParameterData{
Name: "param",
Type: previewtypes.ParameterTypeNumber,
FormType: provider.ParameterFormTypeInput,
Mutable: true,
Validations: []*previewtypes.ParameterValidation{
{Monotonic: ptr.Ref(tc.monotonic)},
},
},
Value: previewtypes.StringLiteral(tc.cur),
Diagnostics: nil,
},
Value: previewtypes.StringLiteral(tc.cur),
Diagnostics: nil,
},
), nil)
render.EXPECT().
Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
AnyTimes().
Return(renderResult(
previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: "param",
Type: previewtypes.ParameterTypeNumber,
FormType: provider.ParameterFormTypeInput,
Mutable: true,
Validations: []*previewtypes.ParameterValidation{
{Monotonic: ptr.Ref(tc.monotonic)},
},
},
Value: previewtypes.StringLiteral(tc.cur),
Diagnostics: nil,
},
), nil)
}, nil)
var previousValues []database.WorkspaceBuildParameter
if tc.prev != "" {
@@ -232,172 +205,4 @@ func TestResolveParameters(t *testing.T) {
})
}
})
t.Run("BaselineRenderDoesNotRequestSecretRequirementsWhenDeactivatingRequirement", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
render := rendermock.NewMockRenderer(ctrl)
ownerID := uuid.New()
gomock.InOrder(
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
Return(renderResult(stringParameter("use_github", "true")), nil),
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "false"}, gomock.Any()).
Return(renderResult(stringParameter("use_github", "false")), nil),
)
ctx := testutil.Context(t, testutil.WaitShort)
values, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
[]codersdk.WorkspaceBuildParameter{{Name: "use_github", Value: "false"}},
[]database.TemplateVersionPresetParameter{},
)
require.NoError(t, err)
require.Equal(t, map[string]string{"use_github": "false"}, values)
})
t.Run("SkipSecretRequirementsAllowsFinalMissingSecrets", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
render := rendermock.NewMockRenderer(ctrl)
ownerID := uuid.New()
gomock.InOrder(
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
Return(renderResult(stringParameter("use_github", "true")), nil),
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
Return(renderResultWithSecretRequirements(
[]codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT",
Satisfied: false,
}},
stringParameter("use_github", "true"),
), nil),
)
ctx := testutil.Context(t, testutil.WaitShort)
values, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
nil,
nil,
dynamicparameters.SkipSecretRequirements(),
)
require.NoError(t, err)
require.Equal(t, map[string]string{"use_github": "true"}, values)
})
t.Run("FinalMissingSecretsBlockByDefault", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
render := rendermock.NewMockRenderer(ctrl)
ownerID := uuid.New()
gomock.InOrder(
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
Return(renderResult(stringParameter("use_github", "true")), nil),
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}, gomock.Any()).
Return(renderResultWithSecretRequirements(
[]codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT",
Satisfied: false,
}},
stringParameter("use_github", "true"),
), nil),
)
ctx := testutil.Context(t, testutil.WaitShort)
_, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
nil,
nil,
)
require.Error(t, err)
resp, ok := httperror.IsResponder(err)
require.True(t, ok)
_, respErr := resp.Response()
require.Contains(t, respErr.Detail, "Missing required secrets")
require.Contains(t, respErr.Detail, "env GITHUB_TOKEN: Add a GitHub PAT")
})
t.Run("FinalRenderErrorSuppressesMissingSecretSynthesis", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
render := rendermock.NewMockRenderer(ctrl)
ownerID := uuid.New()
gomock.InOrder(
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}).
Return(renderResult(stringParameter("use_github", "true")), nil),
render.EXPECT().
Render(gomock.Any(), ownerID, map[string]string{"use_github": "true"}, gomock.Any()).
Return(renderResultWithSecretRequirements(
[]codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT",
Satisfied: false,
}},
stringParameter("use_github", "true"),
), hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Render failed",
Detail: "Template parameter expression failed.",
}}),
)
ctx := testutil.Context(t, testutil.WaitShort)
_, err := dynamicparameters.ResolveParameters(ctx, ownerID, render, false,
[]database.WorkspaceBuildParameter{{Name: "use_github", Value: "true"}},
nil,
nil,
)
require.Error(t, err)
resp, ok := httperror.IsResponder(err)
require.True(t, ok)
_, respErr := resp.Response()
require.Contains(t, respErr.Detail, "Render failed")
require.NotContains(t, respErr.Detail, "Missing required secrets")
})
}
func stringParameter(name string, value string) previewtypes.Parameter {
return previewtypes.Parameter{
ParameterData: previewtypes.ParameterData{
Name: name,
Type: previewtypes.ParameterTypeString,
FormType: provider.ParameterFormTypeInput,
Mutable: true,
DefaultValue: previewtypes.StringLiteral(value),
},
Value: previewtypes.StringLiteral(value),
}
}
func renderResult(params ...previewtypes.Parameter) *dynamicparameters.RenderResult {
return &dynamicparameters.RenderResult{
Output: &preview.Output{
Parameters: params,
},
}
}
func renderResultWithSecretRequirements(reqs []codersdk.SecretRequirementStatus, params ...previewtypes.Parameter) *dynamicparameters.RenderResult {
return &dynamicparameters.RenderResult{
Output: &preview.Output{
Parameters: params,
},
SecretRequirements: reqs,
}
}
-100
View File
@@ -1,100 +0,0 @@
package dynamicparameters
import (
"context"
"slices"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
previewtypes "github.com/coder/preview/types"
)
// EvaluateSecretMismatch reports whether the given template version
// declares coder_secret requirements that the workspace owner's secrets
// do not satisfy. Returns false (no mismatch) when the renderer cannot
// authoritatively evaluate the requirements; the reason is logged at the
// appropriate level so operators can distinguish a forbidden caller
// (expected for template admins) from a genuine renderer or DB failure.
// Returns ErrTemplateVersionNotReady when the version's provisioner job
// has not yet completed; callers should treat that as "unknown" and
// leave SecretMismatch false.
func EvaluateSecretMismatch(
ctx context.Context,
logger slog.Logger,
db database.Store,
cache files.FileAcquirer,
version database.TemplateVersion,
ownerID uuid.UUID,
buildParams []database.WorkspaceBuildParameter,
) (bool, error) {
paramValues := slice.ToMapFunc(buildParams, func(p database.WorkspaceBuildParameter) (string, string) {
return p.Name, p.Value
})
renderer, err := Prepare(ctx, db, cache, version.ID,
WithTemplateVersion(version),
WithLogger(logger))
if err != nil {
return false, err
}
defer renderer.Close()
result, diags := renderer.Render(ctx, ownerID, paramValues, IncludeSecretRequirements())
// Three distinct "unknown" cases. Returning false from any of them
// matches the resolve-autostart handler's semantics, but they have
// very different operator implications, so we log accordingly. The
// renderer already logs its own diagnostics through the same logger,
// so we omit them here to avoid duplication.
if result.Output == nil {
logger.Warn(ctx,
"secret requirement evaluation produced no preview output; treating as unknown",
slog.F("template_version_id", version.ID),
)
return false, nil
}
switch secretValidationBlockerCode(diags) {
case DiagCodeOwnerSecretsFetchFailed:
logger.Warn(ctx,
"failed to fetch owner secrets during requirement evaluation; treating as unknown",
slog.F("template_version_id", version.ID),
)
return false, nil
case DiagCodeSecretValidationForbidden:
// Expected when a caller without user_secret:read on the owner
// hits the renderer, e.g. a template admin viewing another user's
// workspace. Debug-level keeps production volume sane while
// preserving visibility under trace logging.
logger.Debug(ctx,
"secret requirement evaluation forbidden for caller; treating as unknown",
slog.F("template_version_id", version.ID),
)
return false, nil
}
return slices.ContainsFunc(result.SecretRequirements,
func(s codersdk.SecretRequirementStatus) bool { return !s.Satisfied }), nil
}
// secretValidationBlockerCode returns the first diagnostic code among the
// codes that indicate secret-requirement evaluation could not be
// performed. Returns the empty string if no such diagnostic is present.
//
// ExtractDiagnosticExtra walks the wrapped-extra chain so we still
// detect our marker when another extra has been chained on top by
// preview's SetDiagnosticExtra.
func secretValidationBlockerCode(diags hcl.Diagnostics) string {
for _, d := range diags {
extra := previewtypes.ExtractDiagnosticExtra(d)
switch extra.Code {
case DiagCodeOwnerSecretsFetchFailed, DiagCodeSecretValidationForbidden:
return extra.Code
}
}
return ""
}
@@ -1,112 +0,0 @@
package dynamicparameters
import (
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/stretchr/testify/require"
previewtypes "github.com/coder/preview/types"
)
func TestSecretValidationBlockerCode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in hcl.Diagnostics
want string
}{
{
name: "Empty",
in: hcl.Diagnostics{},
want: "",
},
{
name: "MissingSecretIsNotBlocking",
in: hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Missing required secrets",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeMissingSecret,
},
}},
want: "",
},
{
name: "Forbidden",
in: hcl.Diagnostics{{
Severity: hcl.DiagWarning,
Summary: "Cannot validate secret requirements",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeSecretValidationForbidden,
},
}},
want: DiagCodeSecretValidationForbidden,
},
{
name: "FetchFailed",
in: hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Failed to fetch owner secrets",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeOwnerSecretsFetchFailed,
},
}},
want: DiagCodeOwnerSecretsFetchFailed,
},
{
name: "DiagnosticWithNoExtraIsIgnored",
in: hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Some other error",
}},
want: "",
},
{
name: "MixedKeepsLookingUntilMatch",
in: hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Missing required secrets",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeMissingSecret,
},
},
{
Severity: hcl.DiagError,
Summary: "Failed to fetch owner secrets",
Extra: previewtypes.DiagnosticExtra{
Code: DiagCodeOwnerSecretsFetchFailed,
},
},
},
want: DiagCodeOwnerSecretsFetchFailed,
},
{
// SetDiagnosticExtra wraps any pre-existing extra into
// previewtypes.DiagnosticExtra.Wrapped. ExtractDiagnosticExtra
// walks that chain. A naive type assertion would miss it.
name: "WrappedExtraIsDetected",
in: func() hcl.Diagnostics {
d := &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Cannot validate secret requirements",
Extra: "some other extra",
}
previewtypes.SetDiagnosticExtra(d, previewtypes.DiagnosticExtra{
Code: DiagCodeSecretValidationForbidden,
})
return hcl.Diagnostics{d}
}(),
want: DiagCodeSecretValidationForbidden,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, secretValidationBlockerCode(tc.in))
})
}
}
+3 -5
View File
@@ -39,7 +39,7 @@ func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRe
}, nil
}
func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string, _ ...RenderOption) (*RenderResult, hcl.Diagnostics) {
func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) {
params := r.staticParams
for i := range params {
param := &params[i]
@@ -52,10 +52,8 @@ func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]
param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value))
}
return &RenderResult{
Output: &preview.Output{
Parameters: params,
},
return &preview.Output{
Parameters: params,
}, hcl.Diagnostics{
{
// Only a warning because the form does still work.
@@ -1,20 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_parameter" "use_github" {
name = "use_github"
type = "bool"
default = "false"
mutable = true
}
data "coder_secret" "gh" {
count = data.coder_parameter.use_github.value == "true" ? 1 : 0
env = "GITHUB_TOKEN"
help_message = "Add a GitHub PAT"
}
@@ -1,17 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_secret" "env_req" {
env = "GITHUB_TOKEN"
help_message = "needs env"
}
data "coder_secret" "file_req" {
file = "~/.ssh/id_rsa"
help_message = "needs file"
}
@@ -1,12 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_secret" "gh" {
env = "GITHUB_TOKEN"
help_message = "Add a GitHub PAT with env=GITHUB_TOKEN"
}
+35 -158
View File
@@ -8,23 +8,16 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/usersecretspubsub"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
const initialDynamicParametersResponseID = -1
// @Summary Evaluate dynamic parameters for template version
// @ID evaluate-dynamic-parameters-for-template-version
// @Security CoderSessionToken
@@ -70,7 +63,7 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter
}
api.templateVersionDynamicParameters(true, codersdk.DynamicParametersRequest{
ID: initialDynamicParametersResponseID,
ID: -1,
Inputs: map[string]string{},
OwnerID: userID,
})(rw, r)
@@ -89,7 +82,6 @@ func (api *API) templateVersionDynamicParameters(listen bool, initial codersdk.D
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersion.ID,
dynamicparameters.WithTemplateVersion(templateVersion),
dynamicparameters.WithLogger(api.Logger.Named("dynamicparameters")),
)
if err != nil {
if httpapi.Is404Error(err) {
@@ -124,7 +116,15 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
ctx := r.Context()
// Send an initial form state, computed without any user input.
response := renderDynamicParametersResponse(ctx, render, 0, initial.OwnerID, initial.Inputs)
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: 0,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
@@ -149,43 +149,30 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
api.Logger,
)
secretEvents := make(chan uuid.UUID, 1)
secretSubscriber := &parameterSecretEventSubscriber{
api: api,
events: secretEvents,
}
secretSubscriber.UpdateOwnerSubscription(ctx, initial.OwnerID)
defer secretSubscriber.Close()
sender := dynamicParametersResponseSender{
stream: stream,
render: render,
}
// Send an initial form state, computed without any user input.
if !sender.Send(ctx, initialDynamicParametersResponseID, initial.OwnerID, initial.Inputs) {
result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs)
response := codersdk.DynamicParametersResponse{
ID: -1, // Always start with -1.
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
err = stream.Send(response)
if err != nil {
stream.Drop()
return
}
// As the user types into the form or updates secrets in another client,
// reprocess the state using their input and respond with updates.
// As the user types into the form, reprocess the state using their input,
// and respond with updates.
updates := stream.Chan()
ownerID := initial.OwnerID
inputs := initial.Inputs
lastResponseID := initialDynamicParametersResponseID
for {
select {
case <-ctx.Done():
stream.Close(websocket.StatusGoingAway)
return
case eventOwnerID := <-secretEvents:
if eventOwnerID != ownerID {
continue
}
lastResponseID = nextDynamicParametersResponseID(lastResponseID, lastResponseID+1)
if !sender.Send(ctx, lastResponseID, ownerID, inputs) {
return
}
case update, ok := <-updates:
if !ok {
// The connection has been closed, so there is no one to write to
@@ -199,130 +186,20 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
}
ownerID = update.OwnerID
inputs = update.Inputs
secretSubscriber.UpdateOwnerSubscription(ctx, ownerID)
responseID := nextDynamicParametersResponseID(lastResponseID, update.ID)
lastResponseID = responseID
if !sender.Send(ctx, responseID, ownerID, inputs) {
result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs)
response := codersdk.DynamicParametersResponse{
ID: update.ID,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
err = stream.Send(response)
if err != nil {
stream.Drop()
return
}
}
}
}
func renderDynamicParametersResponse(
ctx context.Context,
render dynamicparameters.Renderer,
id int,
ownerID uuid.UUID,
inputs map[string]string,
) codersdk.DynamicParametersResponse {
result, diagnostics := render.Render(ctx, ownerID, inputs, dynamicparameters.IncludeSecretRequirements())
response := codersdk.DynamicParametersResponse{
ID: id,
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result.Output != nil {
response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter)
}
response.SecretRequirements = result.SecretRequirements
return response
}
type dynamicParametersResponseSender struct {
stream *wsjson.Stream[codersdk.DynamicParametersRequest, codersdk.DynamicParametersResponse]
render dynamicparameters.Renderer
}
func (s dynamicParametersResponseSender) Send(
ctx context.Context,
id int,
ownerID uuid.UUID,
inputs map[string]string,
) bool {
response := renderDynamicParametersResponse(ctx, s.render, id, ownerID, inputs)
if err := s.stream.Send(response); err != nil {
s.stream.Drop()
return false
}
return true
}
type parameterSecretEventSubscriber struct {
api *API
events chan uuid.UUID
cancel func()
ownerID uuid.UUID
}
// UpdateOwnerSubscription switches the pubsub subscription to the owner's
// user secret channel. Dynamic parameters can render for a workspace owner
// other than the connected user, so owner changes must update the channel
// that drives secret requirement refreshes.
func (s *parameterSecretEventSubscriber) UpdateOwnerSubscription(ctx context.Context, ownerID uuid.UUID) {
if ownerID == s.ownerID {
return
}
if s.cancel != nil {
s.Close()
}
// Websocket authorization uses the actor snapshot from connection
// creation, matching the rest of the websocket handlers.
if !s.api.canSubscribeUserSecretEvents(ctx, ownerID) {
s.ownerID = ownerID
return
}
s.ownerID = ownerID
subscribedOwnerID := ownerID
cancel, err := s.api.Pubsub.Subscribe(usersecretspubsub.Channel(ownerID), func(context.Context, []byte) {
s.notify(subscribedOwnerID)
})
if err != nil {
// Leave the owner unset so transient pubsub failures can be
// retried on the next update for this owner.
s.ownerID = uuid.Nil
s.api.Logger.Warn(ctx, "failed to subscribe to user secret events",
slog.F("user_id", ownerID),
slog.Error(err),
)
return
}
s.cancel = cancel
}
func (s *parameterSecretEventSubscriber) Close() {
if s.cancel == nil {
return
}
s.cancel()
s.cancel = nil
}
func (s *parameterSecretEventSubscriber) notify(ownerID uuid.UUID) {
select {
case s.events <- ownerID:
default:
}
}
func nextDynamicParametersResponseID(lastResponseID int, requestID int) int {
if requestID <= lastResponseID {
return lastResponseID + 1
}
return requestID
}
func (api *API) canSubscribeUserSecretEvents(ctx context.Context, ownerID uuid.UUID) bool {
roles, ok := dbauthz.ActorFromContext(ctx)
if !ok {
api.Logger.Error(ctx, "no authorization actor for user secret event subscription")
return false
}
return api.HTTPAuth.Authorizer.Authorize(
ctx,
roles,
policy.ActionRead,
rbac.ResourceUserSecret.WithOwner(ownerID.String()),
) == nil
}
-148
View File
@@ -1,148 +0,0 @@
package coderd
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/testutil"
)
func TestNextDynamicParametersResponseID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lastResponseID int
requestID int
want int
}{
{
name: "request ID advances response ID",
lastResponseID: 1,
requestID: 4,
want: 4,
},
{
name: "request ID collision advances response ID",
lastResponseID: 4,
requestID: 4,
want: 5,
},
{
name: "stale request ID advances response ID",
lastResponseID: 4,
requestID: 2,
want: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := nextDynamicParametersResponseID(tt.lastResponseID, tt.requestID)
require.Equal(t, tt.want, got)
})
}
}
func TestCanSubscribeUserSecretEventsRequiresSecretRead(t *testing.T) {
t.Parallel()
ownerID := uuid.New()
actor := rbac.Subject{ID: uuid.NewString()}
t.Run("allowed", func(t *testing.T) {
t.Parallel()
auth := &recordingAuthorizer{}
api := &API{
Options: &Options{
Logger: testutil.Logger(t),
},
HTTPAuth: &HTTPAuthorizer{
Authorizer: auth,
Logger: testutil.Logger(t),
},
}
ctx := dbauthz.As(t.Context(), actor) //nolint:gocritic // Testing authorization from the request context.
require.True(t, api.canSubscribeUserSecretEvents(ctx, ownerID))
require.Len(t, auth.calls, 1)
require.Equal(t, actor, auth.calls[0].Actor)
require.Equal(t, policy.ActionRead, auth.calls[0].Action)
require.Equal(t, rbac.ResourceUserSecret.Type, auth.calls[0].Object.Type)
require.Equal(t, ownerID.String(), auth.calls[0].Object.Owner)
})
t.Run("denied", func(t *testing.T) {
t.Parallel()
auth := &recordingAuthorizer{err: xerrors.New("denied")}
api := &API{
Options: &Options{
Logger: testutil.Logger(t),
},
HTTPAuth: &HTTPAuthorizer{
Authorizer: auth,
Logger: testutil.Logger(t),
},
}
ctx := dbauthz.As(t.Context(), actor) //nolint:gocritic // Testing authorization from the request context.
require.False(t, api.canSubscribeUserSecretEvents(ctx, ownerID))
require.Len(t, auth.calls, 1)
})
t.Run("no actor", func(t *testing.T) {
t.Parallel()
auth := &recordingAuthorizer{}
logger := slogtest.Make(t, &slogtest.Options{
IgnoredErrorIs: []error{},
IgnoreErrorFn: func(entry slog.SinkEntry) bool {
return entry.Message == "no authorization actor for user secret event subscription"
},
})
api := &API{
Options: &Options{
Logger: logger,
},
HTTPAuth: &HTTPAuthorizer{
Authorizer: auth,
Logger: logger,
},
}
require.False(t, api.canSubscribeUserSecretEvents(context.Background(), ownerID))
require.Empty(t, auth.calls)
})
}
type recordingAuthorizer struct {
err error
calls []rbac.AuthCall
}
func (a *recordingAuthorizer) Authorize(_ context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error {
a.calls = append(a.calls, rbac.AuthCall{
Actor: subject,
Action: action,
Object: object,
})
return a.err
}
func (*recordingAuthorizer) Prepare(context.Context, rbac.Subject, policy.Action, string) (rbac.PreparedAuthorized, error) {
//nolint:nilnil // Prepare is unused by these tests.
return nil, nil
}
+8 -465
View File
@@ -16,7 +16,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
@@ -387,433 +386,11 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
coderdtest.AssertParameter(t, "variable_values", preview.Parameters).
Exists().Value("austin")
})
t.Run("MissingSecret", 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.WaitShort)
previews := setup.stream.Chan()
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
for _, diag := range preview.Diagnostics {
require.NotEqual(t, dynamicparameters.DiagCodeMissingSecret, diag.Extra.Code)
}
require.Equal(t, []codersdk.SecretRequirementStatus{{
Env: "GITHUB_TOKEN",
HelpMessage: "Add a GitHub PAT with env=GITHUB_TOKEN",
Satisfied: false,
}}, 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.
t.Run("SecretRequirementDoesNotBlockStopOrDelete", 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,
})
_ = setup.stream.Close(websocket.StatusGoingAway)
ctx := testutil.Context(t, testutil.WaitLong)
// Owner must satisfy the coder_secret requirement to create
// the workspace; delete it later to provoke the bug scenario.
_, err = setup.client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_test",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
require.NoError(t, setup.client.DeleteUserSecret(ctx, codersdk.Me, "github-token"))
// Start on the now-unsatisfied requirement must still fail;
// otherwise we've over-filtered the diagnostic.
_, err = setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
})
require.Error(t, err, "start must still reject unsatisfied secret requirement")
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Contains(t, sdkErr.Detail, "Missing required secrets")
require.Contains(t, sdkErr.Detail, "env GITHUB_TOKEN")
// Stop must succeed despite the unsatisfied requirement.
stop, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, stop.ID)
// Delete must succeed despite the unsatisfied requirement.
del, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, del.ID)
})
}
// TestResolveAutostartPreservesParameterMismatchOnSecretEvalError exercises
// the handler's default switch arm: when EvaluateSecretMismatch returns a
// non-ErrTemplateVersionNotReady error, the handler must log and treat
// SecretMismatch as "unknown" without dropping the already-computed
// ParameterMismatch signal.
func TestResolveAutostartPreservesParameterMismatchOnSecretEvalError(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
// Wrap the DB so we can fail the renderer's GetTemplateVersionTerraformValues
// call. Toggle is flipped on after setup so initial template version
// processing succeeds.
reject := &dbRejectTemplateVersionTerraformValues{Store: db}
noRequirementsTF := []byte(`terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
`)
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
db: reject,
ps: ps,
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: noRequirementsTF,
})
_ = setup.stream.Close(websocket.StatusGoingAway)
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID,
func(req *codersdk.CreateWorkspaceRequest) {
req.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
// Push a v2 that adds a required-no-default parameter so resolve-autostart
// computes ParameterMismatch=true. The new version becomes active via
// DynamicParameterTemplate's UpdateActiveTemplateVersion call.
paramRequiredTF := []byte(`terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_parameter" "required_param" {
name = "required_param"
type = "string"
}
`)
// StaticParams populates the legacy template_version_parameters table via
// the GraphComplete response. resolve-autostart reads from this table to
// determine ParameterMismatch.
_, _ = coderdtest.DynamicParameterTemplate(t, setup.dynamicParamsClient,
wrk.OrganizationID,
coderdtest.DynamicParameterTemplateParams{
MainTF: string(paramRequiredTF),
TemplateID: setup.template.ID,
StaticParams: []*proto.RichParameter{{
Name: "required_param",
Type: "string",
Required: true,
}},
})
// Arm the rejection only for the resolve-autostart call. Setup has
// already completed, so all earlier calls passed through.
reject.SetReject(true)
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := setup.client.ResolveAutostart(ctx, wrk.ID.String())
require.NoError(t, err, "resolve-autostart must not 500 when secret evaluation fails")
require.True(t, resp.ParameterMismatch, "ParameterMismatch should be preserved across secret evaluation failure")
require.False(t, resp.SecretMismatch, "SecretMismatch should be unknown (false) when evaluation fails")
}
// TestResolveAutostartSecretRequirements is the PLAT-81 backend coverage:
// resolve-autostart must surface coder_secret requirements declared by the
// active template version that the workspace owner's secrets do not
// satisfy. The dashboard banner uses this to tell the user autostart
// cannot run the auto-update build until they create the missing secrets.
func TestResolveAutostartSecretRequirements(t *testing.T) {
t.Parallel()
noRequirementsTF := []byte(`terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
`)
secretRequiredTF, err := os.ReadFile("testdata/parameters/secret_required/main.tf")
require.NoError(t, err)
// v1 has no secret requirements; we need a workspace to exist so
// resolve-autostart enters its version-mismatch branch.
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: noRequirementsTF,
})
_ = setup.stream.Close(websocket.StatusGoingAway)
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID,
func(req *codersdk.CreateWorkspaceRequest) {
req.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
// Push v2 with a coder_secret requirement and make it active.
_, _ = coderdtest.DynamicParameterTemplate(t, setup.dynamicParamsClient,
wrk.OrganizationID,
coderdtest.DynamicParameterTemplateParams{
MainTF: string(secretRequiredTF),
TemplateID: setup.template.ID,
})
t.Run("OwnerSeesMismatchThenSatisfies", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Owner has no GITHUB_TOKEN secret; resolve-autostart must surface
// the unsatisfied requirement.
resp, err := setup.client.ResolveAutostart(ctx, wrk.ID.String())
require.NoError(t, err)
require.False(t, resp.ParameterMismatch)
require.True(t, resp.SecretMismatch)
// Creating the matching secret must clear the entry without further
// template changes.
_, err = setup.client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "github-token",
Value: "ghp_test",
EnvName: "GITHUB_TOKEN",
})
require.NoError(t, err)
resp, err = setup.client.ResolveAutostart(ctx, wrk.ID.String())
require.NoError(t, err)
require.False(t, resp.ParameterMismatch)
require.False(t, resp.SecretMismatch)
})
t.Run("ForbiddenCallerSeesNoMismatch", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// The template-admin client can read the workspace but lacks
// user_secret:read on the workspace owner. The renderer's secret
// fetch produces a forbidden diagnostic, and the handler must
// treat that as "unknown" and report SecretMismatch=false rather
// than leaking a 500 or a stale true value.
resp, err := setup.dynamicParamsClient.ResolveAutostart(ctx, wrk.ID.String())
require.NoError(t, err)
require.False(t, resp.SecretMismatch)
})
}
type setupDynamicParamsTestParams struct {
db database.Store
ps pubsub.Pubsub
authorizer rbac.Authorizer
provisionerDaemonVersion string
mainTF []byte
modulesArchive []byte
@@ -825,18 +402,16 @@ type setupDynamicParamsTestParams struct {
}
type dynamicParamsTest struct {
client *codersdk.Client
dynamicParamsClient *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
template codersdk.Template
client *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,
})
@@ -871,11 +446,10 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
})
return dynamicParamsTest{
client: ownerClient,
dynamicParamsClient: templateAdmin,
api: api,
stream: stream,
template: tpl,
client: ownerClient,
api: api,
stream: stream,
template: tpl,
}
}
@@ -910,34 +484,3 @@ func (d *dbRejectGitSSHKey) GetGitSSHKey(ctx context.Context, userID uuid.UUID)
return d.Store.GetGitSSHKey(ctx, userID)
}
// dbRejectTemplateVersionTerraformValues wraps a Store so the dynamic
// parameter renderer's GetTemplateVersionTerraformValues call can be made
// to fail on demand. This forces the resolve-autostart handler into the
// default switch arm where EvaluateSecretMismatch returns a non-
// ErrTemplateVersionNotReady error.
type dbRejectTemplateVersionTerraformValues struct {
database.Store
rejectMu sync.RWMutex
reject bool
}
// SetReject toggles whether GetTemplateVersionTerraformValues should
// return an error or passthrough to the underlying store.
func (d *dbRejectTemplateVersionTerraformValues) SetReject(reject bool) {
d.rejectMu.Lock()
defer d.rejectMu.Unlock()
d.reject = reject
}
func (d *dbRejectTemplateVersionTerraformValues) GetTemplateVersionTerraformValues(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersionTerraformValue, error) {
d.rejectMu.RLock()
reject := d.reject
d.rejectMu.RUnlock()
if reject {
return database.TemplateVersionTerraformValue{}, xerrors.New("forcing a fake error")
}
return d.Store.GetTemplateVersionTerraformValues(ctx, templateVersionID)
}
@@ -591,26 +591,6 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
}
}
// Fetch user secrets for build-time injection, but only on start
// transitions where the workspace actually needs them.
var userSecrets []*sdkproto.UserSecretValue
if workspaceBuild.Transition == database.WorkspaceTransitionStart {
dbSecrets, err := s.Database.ListUserSecretsWithValues(ctx, owner.ID)
if err != nil {
return nil, failJob(fmt.Sprintf("get user secrets: %s", err))
}
for _, secret := range dbSecrets {
if secret.EnvName == "" && secret.FilePath == "" {
continue
}
userSecrets = append(userSecrets, &sdkproto.UserSecretValue{
EnvName: secret.EnvName,
FilePath: secret.FilePath,
Value: []byte(secret.Value),
})
}
}
transition, err := convertWorkspaceTransition(workspaceBuild.Transition)
if err != nil {
return nil, failJob(fmt.Sprintf("convert workspace transition: %s", err))
@@ -793,8 +773,7 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
TaskPrompt: task.Prompt,
TemplateVersionModulesFile: versionModulesFile,
},
LogLevel: input.LogLevel,
UserSecrets: userSecrets,
LogLevel: input.LogLevel,
},
}
case database.ProvisionerJobTypeTemplateVersionDryRun:
@@ -856,368 +856,6 @@ func TestAcquireJob(t *testing.T) {
require.NoError(t, err)
require.JSONEq(t, string(want), string(got))
})
t.Run(tc.name+"_UserSecrets", func(t *testing.T) {
t.Parallel()
srv, db, ps, pd := setup(t, false, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
user := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: pd.OrganizationID,
})
dbgen.GitSSHKey(t, db, database.GitSSHKey{UserID: user.ID})
// Create secrets: 4 valid + 1 that should be filtered out.
insert1 := database.UserSecret{ID: uuid.New(), UserID: user.ID, Name: "github-token", EnvName: "GITHUB_TOKEN", Value: "ghp_xxxx"}
secret1 := dbgen.UserSecret(t, db, insert1, func(p *database.CreateUserSecretParams) { p.FilePath = "" })
insert2 := database.UserSecret{ID: uuid.New(), UserID: user.ID, Name: "ssh-key", FilePath: "~/.ssh/id_rsa", Value: "private-key"}
secret2 := dbgen.UserSecret(t, db, insert2, func(p *database.CreateUserSecretParams) { p.EnvName = "" })
insert3 := database.UserSecret{ID: uuid.New(), UserID: user.ID, Name: "both", EnvName: "BOTH", FilePath: "/etc/both", Value: "both-val"}
secret3 := dbgen.UserSecret(t, db, insert3)
insert4 := database.UserSecret{ID: uuid.New(), UserID: user.ID, Name: "empty-value", Value: "", EnvName: "EMPTY_VALUE", FilePath: "/etc/empty-value"}
secret4 := dbgen.UserSecret(t, db, insert4, func(p *database.CreateUserSecretParams) { p.Value = "" })
insert5 := database.UserSecret{ID: uuid.New(), UserID: user.ID, Name: "no-injection", Value: "no-injection"}
_ = dbgen.UserSecret(t, db, insert5, func(p *database.CreateUserSecretParams) { p.EnvName = ""; p.FilePath = "" })
template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
CreatedBy: user.ID,
})
file := dbgen.File(t, db, database.File{CreatedBy: user.ID})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
OrganizationID: pd.OrganizationID,
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
JobID: uuid.New(),
})
// Import version job
_ = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
OrganizationID: pd.OrganizationID,
ID: version.JobID,
InitiatorID: user.ID,
FileID: file.ID,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
Type: database.ProvisionerJobTypeTemplateVersionImport,
Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{
TemplateVersionID: version.ID,
})),
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: template.ID,
OwnerID: user.ID,
OrganizationID: pd.OrganizationID,
})
buildID := uuid.New()
dbJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
OrganizationID: pd.OrganizationID,
InitiatorID: user.ID,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
FileID: file.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: buildID,
})),
Tags: pd.Tags,
})
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
ID: buildID,
WorkspaceID: workspace.ID,
BuildNumber: 1,
JobID: dbJob.ID,
TemplateVersionID: version.ID,
Transition: database.WorkspaceTransitionStart,
Reason: database.BuildReasonInitiator,
})
startPublished := make(chan struct{})
var closed bool
closeStartSubscribe, err := ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID),
wspubsub.HandleWorkspaceEvent(
func(_ context.Context, e wspubsub.WorkspaceEvent, err error) {
if err != nil {
return
}
if e.Kind == wspubsub.WorkspaceEventKindStateChange && e.WorkspaceID == workspace.ID {
if !closed {
close(startPublished)
closed = true
}
}
}))
require.NoError(t, err)
defer closeStartSubscribe()
// Grab jobs until we find the workspace build job.
var job *proto.AcquiredJob
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
job, err = tc.acquire(ctx, srv)
require.NoError(t, err)
_, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_)
return ok
}, testutil.IntervalMedium)
select {
case <-startPublished:
case <-time.After(testutil.WaitShort):
t.Fatalf("timed out waiting for workspace build job to start")
}
wb := job.Type.(*proto.AcquiredJob_WorkspaceBuild_).WorkspaceBuild
require.Len(t, wb.UserSecrets, 4, "expected 4 secrets (the one with empty env_name and file_path should be filtered)")
// Re-sort by (env_name+file_path) before asserting field values.
// The terraform-provider-coder contract does not require a
// specific secret order, so this test intentionally does not
// assert the order produced by ListUserSecretsWithValues.
slices.SortFunc(wb.UserSecrets, func(a, b *sdkproto.UserSecretValue) int {
return strings.Compare(a.EnvName+a.FilePath, b.EnvName+b.FilePath)
})
// After sorting: []{secret3, secret4, secret1, secret2}
require.Equal(t, secret3.EnvName, wb.UserSecrets[0].EnvName)
require.Equal(t, secret3.FilePath, wb.UserSecrets[0].FilePath)
require.Equal(t, []byte(secret3.Value), wb.UserSecrets[0].Value)
require.Equal(t, secret4.EnvName, wb.UserSecrets[1].EnvName)
require.Equal(t, secret4.FilePath, wb.UserSecrets[1].FilePath)
require.Equal(t, []byte(secret4.Value), wb.UserSecrets[1].Value)
require.Equal(t, secret1.EnvName, wb.UserSecrets[2].EnvName)
require.Equal(t, secret1.FilePath, wb.UserSecrets[2].FilePath)
require.Equal(t, []byte(secret1.Value), wb.UserSecrets[2].Value)
require.Equal(t, secret2.EnvName, wb.UserSecrets[3].EnvName)
require.Equal(t, secret2.FilePath, wb.UserSecrets[3].FilePath)
require.Equal(t, []byte(secret2.Value), wb.UserSecrets[3].Value)
})
for _, transitionCase := range []struct {
name string
transition database.WorkspaceTransition
}{
{
name: "Stop",
transition: database.WorkspaceTransitionStop,
},
{
name: "Delete",
transition: database.WorkspaceTransitionDelete,
},
} {
t.Run(tc.name+"_UserSecrets"+transitionCase.name+"Transition", func(t *testing.T) {
// Secrets must never be populated on non-start transitions. The
// terraform-provider-coder data source intentionally returns empty
// values on stop/delete so that workspaces with revoked or deleted
// secrets can still be torn down.
t.Parallel()
srv, db, ps, pd := setup(t, false, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
user := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: pd.OrganizationID,
})
dbgen.GitSSHKey(t, db, database.GitSSHKey{UserID: user.ID})
// Give the owner a secret so we can prove it is not forwarded on a
// transition.
authCtx := dbauthz.AsSystemRestricted(ctx)
_, err := db.CreateUserSecret(authCtx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
Name: "github-token",
EnvName: "GITHUB_TOKEN",
Value: "must-not-leak",
})
require.NoError(t, err)
template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
CreatedBy: user.ID,
})
file := dbgen.File(t, db, database.File{CreatedBy: user.ID})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
OrganizationID: pd.OrganizationID,
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
JobID: uuid.New(),
})
_ = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
OrganizationID: pd.OrganizationID,
ID: version.JobID,
InitiatorID: user.ID,
FileID: file.ID,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
Type: database.ProvisionerJobTypeTemplateVersionImport,
Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{
TemplateVersionID: version.ID,
})),
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: template.ID,
OwnerID: user.ID,
OrganizationID: pd.OrganizationID,
})
buildID := uuid.New()
dbJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
OrganizationID: pd.OrganizationID,
InitiatorID: user.ID,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
FileID: file.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: buildID,
})),
Tags: pd.Tags,
})
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
ID: buildID,
WorkspaceID: workspace.ID,
BuildNumber: 1,
JobID: dbJob.ID,
TemplateVersionID: version.ID,
Transition: transitionCase.transition,
Reason: database.BuildReasonInitiator,
})
var job *proto.AcquiredJob
for {
job, err = tc.acquire(ctx, srv)
require.NoError(t, err)
if _, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_); ok {
break
}
}
wb := job.Type.(*proto.AcquiredJob_WorkspaceBuild_).WorkspaceBuild
require.Empty(t, wb.UserSecrets)
})
}
t.Run(tc.name+"_UserSecretsDBError", func(t *testing.T) {
// A DB failure fetching user secrets must surface as a provisioner
// job failure rather than being silently treated as "no secrets".
// Silent treatment would let a transient DB error cause a
// workspace to build without the secrets it needs, producing a
// confusing downstream terraform error about missing secrets that
// the user actually owns.
t.Parallel()
srv, db, ps, pd := setup(t, true, &overrides{
wrapDB: func(inner database.Store) database.Store {
return &errOnListUserSecretsWithValues{Store: inner}
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
user := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: pd.OrganizationID,
})
dbgen.GitSSHKey(t, db, database.GitSSHKey{UserID: user.ID})
template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
CreatedBy: user.ID,
})
file := dbgen.File(t, db, database.File{CreatedBy: user.ID})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
OrganizationID: pd.OrganizationID,
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
JobID: uuid.New(),
})
_ = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
OrganizationID: pd.OrganizationID,
ID: version.JobID,
InitiatorID: user.ID,
FileID: file.ID,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
Type: database.ProvisionerJobTypeTemplateVersionImport,
Input: must(json.Marshal(provisionerdserver.TemplateVersionImportJob{
TemplateVersionID: version.ID,
})),
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: template.ID,
OwnerID: user.ID,
OrganizationID: pd.OrganizationID,
})
buildID := uuid.New()
dbJob := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
OrganizationID: pd.OrganizationID,
InitiatorID: user.ID,
Provisioner: database.ProvisionerTypeEcho,
StorageMethod: database.ProvisionerStorageMethodFile,
FileID: file.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: buildID,
})),
Tags: pd.Tags,
})
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
ID: buildID,
WorkspaceID: workspace.ID,
BuildNumber: 1,
JobID: dbJob.ID,
TemplateVersionID: version.ID,
// Only start transitions fetch secrets.
Transition: database.WorkspaceTransitionStart,
Reason: database.BuildReasonInitiator,
})
var acquireErr error
for {
// Keep acquiring until we either get our build back (possible
// for the Deprecated path to return an empty AcquiredJob once
// its long-poll window elapses on unrelated jobs) or propagate
// an error.
job, err := tc.acquire(ctx, srv)
if err != nil {
acquireErr = err
break
}
if job != nil && job.JobId != "" {
t.Fatalf("expected acquire to error, got job %s", job.JobId)
}
}
require.ErrorContains(t, acquireErr, "request job was invalidated",
"DB error should surface as a job invalidation")
require.ErrorContains(t, acquireErr, "get user secrets",
"error should identify the failing operation")
require.ErrorContains(t, acquireErr, "ListUserSecretsWithValues query failed",
"underlying DB error message should be preserved")
// Confirm the provisioner job itself was marked as failed so the
// workspace build does not remain stuck in-progress.
authCtx := dbauthz.AsSystemRestricted(ctx)
gotJob, err := db.GetProvisionerJobByID(authCtx, dbJob.ID)
require.NoError(t, err)
require.True(t, gotJob.Error.Valid, "job should be marked with an error")
require.Contains(t, gotJob.Error.String, "get user secrets")
require.True(t, gotJob.CompletedAt.Valid, "job should be marked complete")
})
}
}
@@ -5157,9 +4795,6 @@ type overrides struct {
auditor audit.Auditor
notificationEnqueuer notifications.Enqueuer
prebuildsOrchestrator agplprebuilds.ReconciliationOrchestrator
// wrapDB wraps the raw DB before dbauthz.New. Use this to inject
// errors or observe calls on specific queries for a single test.
wrapDB func(database.Store) database.Store
}
func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisionerDaemonServer, database.Store, pubsub.Pubsub, database.ProvisionerDaemon) {
@@ -5260,9 +4895,6 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi
// Use an authz wrapped database for the server to ensure permission checks
// work.
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
if ov.wrapDB != nil {
db = ov.wrapDB(db)
}
serverDB := dbauthz.New(db, authorizer, logger, coderdtest.AccessControlStorePointer())
srv, err := provisionerdserver.NewServer(
ov.ctx,
@@ -5407,32 +5039,3 @@ func newFakeUsageInserter() (*coderdtest.UsageInserter, *atomic.Pointer[usage.In
poitr.Store(&inserter)
return fake, poitr
}
// errListUserSecretsWithValues is the sentinel returned by the test wrapper
// below. Its message is matched by assertions that verify the underlying DB
// error propagated through failJob's formatting. The chain is not preserved
// via errors.Is because failJob uses fmt.Sprintf, not %w.
var errListUserSecretsWithValues = xerrors.New("ListUserSecretsWithValues query failed")
// errOnListUserSecretsWithValues is a database.Store wrapper that errors only
// on ListUserSecretsWithValues. All other methods pass through to the
// underlying store. Used to simulate a transient DB failure on the secret
// fetch without breaking the rest of the acquire flow (user lookup, job
// update, etc.).
type errOnListUserSecretsWithValues struct {
database.Store
}
func (*errOnListUserSecretsWithValues) ListUserSecretsWithValues(context.Context, uuid.UUID) ([]database.UserSecret, error) {
return nil, errListUserSecretsWithValues
}
// InTx must be overridden to keep the wrapped store visible inside a
// transaction. Without this override, InTx would pass the raw inner store to
// its closure and tests would see the unwrapped behavior from anywhere that
// runs inside a transaction.
func (e *errOnListUserSecretsWithValues) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
return e.Store.InTx(func(tx database.Store) error {
return fn(&errOnListUserSecretsWithValues{Store: tx})
}, opts)
}
-12
View File
@@ -1,12 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_secret" "gh" {
env = "GITHUB_TOKEN"
help_message = "Add a GitHub PAT with env=GITHUB_TOKEN"
}
-35
View File
@@ -10,13 +10,11 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/usersecretspubsub"
"github.com/coder/coder/v2/codersdk"
)
@@ -84,14 +82,6 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
}
aReq.New = secret
api.publishUserSecretEvent(ctx, usersecretspubsub.Event{
Kind: usersecretspubsub.EventKindCreated,
UserID: secret.UserID,
Name: secret.Name,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
})
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret))
}
@@ -263,14 +253,6 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
return
}
api.publishUserSecretEvent(ctx, usersecretspubsub.Event{
Kind: usersecretspubsub.EventKindUpdated,
UserID: secret.UserID,
Name: secret.Name,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
})
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret))
}
@@ -314,12 +296,6 @@ func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) {
}
aReq.Old = deleted
api.publishUserSecretEvent(ctx, usersecretspubsub.Event{
Kind: usersecretspubsub.EventKindDeleted,
UserID: user.ID,
Name: name,
})
rw.WriteHeader(http.StatusNoContent)
}
@@ -391,14 +367,3 @@ func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
return nil
}
}
func (api *API) publishUserSecretEvent(ctx context.Context, event usersecretspubsub.Event) {
if err := usersecretspubsub.Publish(api.Pubsub, event); err != nil {
api.Logger.Warn(ctx, "failed to publish user secret event",
slog.F("user_id", event.UserID),
slog.F("secret_name", event.Name),
slog.F("event_kind", event.Kind),
slog.Error(err),
)
}
}
@@ -1,42 +0,0 @@
package usersecretspubsub
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/pubsub"
)
type EventKind string
const (
EventKindCreated EventKind = "created"
EventKindUpdated EventKind = "updated"
EventKindDeleted EventKind = "deleted"
)
type Event struct {
Kind EventKind `json:"kind"`
UserID uuid.UUID `json:"user_id" format:"uuid"`
Name string `json:"name"`
EnvName string `json:"env_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
func Channel(userID uuid.UUID) string {
return fmt.Sprintf("user_secrets:%s", userID)
}
func Publish(ps pubsub.Publisher, event Event) error {
msg, err := json.Marshal(event)
if err != nil {
return xerrors.Errorf("marshal user secret event: %w", err)
}
if err := ps.Publish(Channel(event.UserID), msg); err != nil {
return xerrors.Errorf("publish user secret event: %w", err)
}
return nil
}
-1
View File
@@ -383,7 +383,6 @@ func (api *API) postWorkspaceBuildsInternal(
DeploymentValues(api.Options.DeploymentValues).
Experiments(api.Experiments).
TemplateVersionPresetID(createBuild.TemplateVersionPresetID).
Logger(api.Logger.Named("wsbuilder")).
BuildMetrics(api.WorkspaceBuilderMetrics)
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
-32
View File
@@ -25,7 +25,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
@@ -795,7 +794,6 @@ func createWorkspace(
Experiments(api.Experiments).
DeploymentValues(api.DeploymentValues).
RichParameterValues(req.RichParameterValues).
Logger(api.Logger.Named("wsbuilder")).
BuildMetrics(api.WorkspaceBuilderMetrics)
if req.TemplateVersionID != uuid.Nil {
builder = builder.VersionID(req.TemplateVersionID)
@@ -2010,36 +2008,6 @@ func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) {
break
}
}
// Surface whether the active template version declares coder_secret
// requirements that the workspace owner's secrets do not satisfy. The
// intention is for this information to inform the workspace update
// requirement so the user knows autostart will not run an auto-update
// build until the missing secrets are satisfied.
//
// Callers without user_secret:read on the workspace owner produce a
// forbidden warning diagnostic. This is treated as "unknown" and
// no mismatch is reported rather than returning a partial answer.
secretMismatch, err := dynamicparameters.EvaluateSecretMismatch(
ctx,
api.Logger.Named("dynamicparameters"),
api.Database, api.FileCache, version, workspace.OwnerID, dbBuildParams,
)
switch {
case err == nil:
response.SecretMismatch = secretMismatch
case xerrors.Is(err, dynamicparameters.ErrTemplateVersionNotReady):
// Active version's provisioner job hasn't completed yet. Leave
// SecretMismatch false.
default:
// Don't drop the already-computed ParameterMismatch signal on a
// renderer infrastructure error. Log and treat as "unknown."
api.Logger.Warn(ctx, "failed to evaluate secret requirements",
slog.F("workspace_id", workspace.ID),
slog.Error(err),
)
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
+4 -19
View File
@@ -17,7 +17,6 @@ import (
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -60,7 +59,6 @@ type Builder struct {
deploymentValues *codersdk.DeploymentValues
experiments codersdk.Experiments
usageChecker UsageChecker
logger slog.Logger
richParameterValues []codersdk.WorkspaceBuildParameter
initiator uuid.UUID
@@ -197,12 +195,6 @@ func (b Builder) Experiments(exp codersdk.Experiments) Builder {
return b
}
func (b Builder) Logger(log slog.Logger) Builder {
// nolint: revive
b.logger = log
return b
}
func (b Builder) Initiator(u uuid.UUID) Builder {
// nolint: revive
b.initiator = u
@@ -776,7 +768,6 @@ func (b *Builder) getDynamicParameterRenderer() (dynamicparameters.Renderer, err
dynamicparameters.WithProvisionerJob(*job),
dynamicparameters.WithTerraformValues(*tfVals),
dynamicparameters.WithTemplateVariableValues(variableValues),
dynamicparameters.WithLogger(b.logger.Named("dynamicparameters")),
)
if err != nil {
return nil, xerrors.Errorf("get template version renderer: %w", err)
@@ -902,16 +893,10 @@ func (b *Builder) getDynamicParameters() (names, values []string, err error) {
return nil, nil, BuildError{http.StatusInternalServerError, "failed to check if first build", err}
}
// Don't let missing secrets block stop or delete.
var resolveOpts []dynamicparameters.ResolveOption
if b.trans != database.WorkspaceTransitionStart {
resolveOpts = append(resolveOpts, dynamicparameters.SkipSecretRequirements())
}
buildValues, err := dynamicparameters.ResolveParameters(b.ctx, b.workspace.OwnerID, render, firstBuild,
lastBuildParameters,
b.richParameterValues,
presetParameterValues,
resolveOpts...)
presetParameterValues)
if err != nil {
return nil, nil, BuildError{http.StatusBadRequest, "resolve parameters", err}
}
@@ -1147,13 +1132,13 @@ func (b *Builder) getDynamicProvisionerTags() (map[string]string, error) {
vals[name] = values[i]
}
result, diags := render.Render(b.ctx, b.workspace.OwnerID, vals)
tagErr := dynamicparameters.CheckTags(result.Output, diags)
output, diags := render.Render(b.ctx, b.workspace.OwnerID, vals)
tagErr := dynamicparameters.CheckTags(output, diags)
if tagErr != nil {
return nil, BuildError{http.StatusBadRequest, "workspace tags validation failed", tagErr}
}
for k, v := range result.Output.WorkspaceTags.Tags() {
for k, v := range output.WorkspaceTags.Tags() {
tags[k] = v
}
@@ -1,70 +0,0 @@
package wsbuilder
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/hashicorp/hcl/v2"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
)
func TestBuilderDynamicProvisionerTagsDoesNotRequestSecretRequirements(t *testing.T) {
t.Parallel()
ownerID := uuid.New()
names := []string{"region"}
values := []string{"us-east"}
render := &tagsPathRenderer{
result: &dynamicparameters.RenderResult{
Output: &preview.Output{
WorkspaceTags: previewtypes.TagBlocks{{
Tags: previewtypes.Tags{{
Key: previewtypes.StringLiteral("region"),
Value: previewtypes.StringLiteral("us-east"),
}},
}},
},
},
}
builder := New(database.Workspace{
ID: uuid.New(),
OwnerID: ownerID,
}, database.WorkspaceTransitionStart, NoopUsageChecker{})
builder.ctx = t.Context()
builder.parameterRender = render
builder.parameterNames = &names
builder.parameterValues = &values
builder.templateVersionJob = &database.ProvisionerJob{
Tags: database.StringMap{
provisionersdk.TagScope: provisionersdk.ScopeUser,
},
}
tags, err := builder.getDynamicProvisionerTags()
require.NoError(t, err)
require.Equal(t, "us-east", tags["region"])
require.Equal(t, ownerID.String(), tags[provisionersdk.TagOwner])
require.Empty(t, render.opts, "tags path should not request secret requirements")
}
type tagsPathRenderer struct {
result *dynamicparameters.RenderResult
diags hcl.Diagnostics
opts []dynamicparameters.RenderOption
}
func (r *tagsPathRenderer) Render(_ context.Context, _ uuid.UUID, _ map[string]string, opts ...dynamicparameters.RenderOption) (*dynamicparameters.RenderResult, hcl.Diagnostics) {
r.opts = opts
return r.result, r.diags
}
func (*tagsPathRenderer) Close() {}