From fb84e723195057f2f5b070c9e41531a199eaa28c Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Wed, 29 Apr 2026 16:38:26 -0700 Subject: [PATCH] feat: add secret requirement contract to dynamic parameters (#24785) Adds structured `secret_requirements` to dynamic parameter responses and enforces missing required secrets during workspace start. Stop, delete, and tag rendering paths skip secret requirement enforcement so unmet secrets do not prevent cleanup. The SDK, generated API docs/types, and backend render/resolver/wsbuilder tests are updated for the new contract. --- coderd/apidoc/docs.go | 23 + coderd/apidoc/swagger.json | 23 + coderd/autobuild/lifecycle_executor.go | 1 + coderd/database/dbauthz/dbauthz.go | 1 + coderd/database/dbauthz/dbauthz_test.go | 13 + coderd/dynamicparameters/render.go | 212 +++++++++- .../dynamicparameters/render_internal_test.go | 394 ++++++++++++++++++ .../rendermock/rendermock.go | 17 +- coderd/dynamicparameters/resolver.go | 91 +++- .../resolver_internal_test.go | 68 +++ coderd/dynamicparameters/resolver_test.go | 293 ++++++++++--- coderd/dynamicparameters/static.go | 8 +- .../testdata/secret_conditional/main.tf | 20 + .../testdata/secret_env_and_file/main.tf | 17 + .../testdata/secret_required/main.tf | 12 + coderd/parameters.go | 22 +- coderd/parameters_test.go | 84 ++++ .../parameters/secret_required/main.tf | 12 + coderd/workspacebuilds.go | 1 + coderd/workspaces.go | 1 + coderd/wsbuilder/wsbuilder.go | 23 +- coderd/wsbuilder/wsbuilder_internal_test.go | 70 ++++ codersdk/parameters.go | 14 +- docs/reference/api/schemas.md | 39 +- docs/reference/api/templates.md | 8 + go.mod | 2 +- go.sum | 4 +- site/src/api/typesGenerated.ts | 9 + 28 files changed, 1382 insertions(+), 100 deletions(-) create mode 100644 coderd/dynamicparameters/render_internal_test.go create mode 100644 coderd/dynamicparameters/resolver_internal_test.go create mode 100644 coderd/dynamicparameters/testdata/secret_conditional/main.tf create mode 100644 coderd/dynamicparameters/testdata/secret_env_and_file/main.tf create mode 100644 coderd/dynamicparameters/testdata/secret_required/main.tf create mode 100644 coderd/testdata/parameters/secret_required/main.tf create mode 100644 coderd/wsbuilder/wsbuilder_internal_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 183951a2ac..bec25890ff 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16199,6 +16199,12 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/codersdk.PreviewParameter" } + }, + "secret_requirements": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SecretRequirementStatus" + } } } }, @@ -19956,6 +19962,23 @@ 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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4bef3d5c83..356d14b6df 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14663,6 +14663,12 @@ "items": { "$ref": "#/definitions/codersdk.PreviewParameter" } + }, + "secret_requirements": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SecretRequirementStatus" + } } } }, @@ -18288,6 +18294,23 @@ } } }, + "codersdk.SecretRequirementStatus": { + "type": "object", + "properties": { + "env": { + "type": "string" + }, + "file": { + "type": "string" + }, + "help_message": { + "type": "string" + }, + "satisfied": { + "type": "boolean" + } + } + }, "codersdk.ServerSentEvent": { "type": "object", "properties": { diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 84fff375e0..c616db0ab3 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -343,6 +343,7 @@ 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 && diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bbc09d3573..615c5aae86 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -270,6 +270,7 @@ 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}, }), diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 96ce142706..48572488d4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6195,6 +6195,19 @@ func TestGetWorkspaceAgentByID_FastPath(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() diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index aa2fc04bd8..d6e3625c9e 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "io/fs" - "log/slog" "sync" "time" @@ -13,14 +12,27 @@ 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`. @@ -28,17 +40,40 @@ import ( // '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) (*preview.Output, hcl.Diagnostics) + Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...RenderOption) (*RenderResult, 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 @@ -90,6 +125,13 @@ 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) @@ -203,12 +245,14 @@ 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, - ownerErrors: make(map[uuid.UUID]error), - close: cache.Close, - tfvarValues: tfVarValues, + 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, }, nil } @@ -216,16 +260,26 @@ type dynamicRenderer struct { db database.Store data *loader templateFS fs.FS + logger slog.Logger ownerErrors map[uuid.UUID]error currentOwner *previewtypes.WorkspaceOwner - tfvarValues map[string]cty.Value + + // ownerSecretErrors caches NotAuthorized denials per owner. + ownerSecretErrors map[uuid.UUID]error + + tfvarValues map[string]cty.Value once sync.Once close func() } -func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { +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) + } + // Always start with the cached error, if we have one. ownerErr := r.ownerErrors[ownerID] if ownerErr == nil { @@ -234,7 +288,7 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values if ownerErr != nil || r.currentOwner == nil { r.ownerErrors[ownerID] = ownerErr - return nil, hcl.Diagnostics{ + return &RenderResult{}, hcl.Diagnostics{ { Severity: hcl.DiagError, Summary: "Failed to fetch workspace owner", @@ -251,13 +305,122 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values ParameterValues: values, Owner: *r.currentOwner, TFVars: r.tfvarValues, - // 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), + // Leave Logger nil so preview discards parser logs. Returning + // those logs to callers would be useful, but they may be large. } - return preview.Preview(ctx, input, r.templateFS) + 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 } func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error { @@ -274,6 +437,23 @@ 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) } diff --git a/coderd/dynamicparameters/render_internal_test.go b/coderd/dynamicparameters/render_internal_test.go new file mode 100644 index 0000000000..9da5744ddf --- /dev/null +++ b/coderd/dynamicparameters/render_internal_test.go @@ -0,0 +1,394 @@ +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) + } + } +} diff --git a/coderd/dynamicparameters/rendermock/rendermock.go b/coderd/dynamicparameters/rendermock/rendermock.go index 996b02a555..d23c6b4705 100644 --- a/coderd/dynamicparameters/rendermock/rendermock.go +++ b/coderd/dynamicparameters/rendermock/rendermock.go @@ -13,7 +13,7 @@ import ( context "context" reflect "reflect" - preview "github.com/coder/preview" + dynamicparameters "github.com/coder/coder/v2/coderd/dynamicparameters" uuid "github.com/google/uuid" hcl "github.com/hashicorp/hcl/v2" gomock "go.uber.org/mock/gomock" @@ -56,16 +56,21 @@ func (mr *MockRendererMockRecorder) Close() *gomock.Call { } // Render mocks base method. -func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string) (*preview.Output, hcl.Diagnostics) { +func (m *MockRenderer) Render(ctx context.Context, ownerID uuid.UUID, values map[string]string, opts ...dynamicparameters.RenderOption) (*dynamicparameters.RenderResult, hcl.Diagnostics) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Render", ctx, ownerID, values) - ret0, _ := ret[0].(*preview.Output) + 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) ret1, _ := ret[1].(hcl.Diagnostics) return ret0, ret1 } // Render indicates an expected call of Render. -func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any) *gomock.Call { +func (mr *MockRendererMockRecorder) Render(ctx, ownerID, values any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), ctx, ownerID, values) + varargs := append([]any{ctx, ownerID, values}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockRenderer)(nil).Render), varargs...) } diff --git a/coderd/dynamicparameters/resolver.go b/coderd/dynamicparameters/resolver.go index b0a5a027c6..9220685931 100644 --- a/coderd/dynamicparameters/resolver.go +++ b/coderd/dynamicparameters/resolver.go @@ -3,6 +3,7 @@ package dynamicparameters import ( "context" "fmt" + "strings" "github.com/google/uuid" "github.com/hashicorp/hcl/v2" @@ -10,6 +11,7 @@ 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" ) @@ -22,11 +24,33 @@ 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, @@ -36,7 +60,12 @@ 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 }) @@ -70,7 +99,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. - output, diags := renderer.Render(ctx, ownerID, previousValuesMap) + result, 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 @@ -78,6 +107,7 @@ 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. @@ -98,10 +128,33 @@ 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. - output, diags = renderer.Render(ctx, ownerID, values.ValuesMap()) + 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, + }, + }) + } + } 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. @@ -228,3 +281,37 @@ 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() +} diff --git a/coderd/dynamicparameters/resolver_internal_test.go b/coderd/dynamicparameters/resolver_internal_test.go new file mode 100644 index 0000000000..5979f10bc0 --- /dev/null +++ b/coderd/dynamicparameters/resolver_internal_test.go @@ -0,0 +1,68 @@ +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)) + }) + } +} diff --git a/coderd/dynamicparameters/resolver_test.go b/coderd/dynamicparameters/resolver_test.go index 4f4785bb02..0084442b62 100644 --- a/coderd/dynamicparameters/resolver_test.go +++ b/coderd/dynamicparameters/resolver_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -32,23 +33,37 @@ func TestResolveParameters(t *testing.T) { render.EXPECT(). Render(gomock.Any(), gomock.Any(), gomock.Any()). AnyTimes(). - 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, + 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) + 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) ctx := testutil.Context(t, testutil.WaitShort) values, err := dynamicparameters.ResolveParameters(ctx, uuid.New(), render, false, []database.WorkspaceBuildParameter{}, // No previous values @@ -81,29 +96,25 @@ func TestResolveParameters(t *testing.T) { render.EXPECT(). Render(gomock.Any(), gomock.Any(), gomock.Any()). // Return the mutable param first - Return(&preview.Output{ - Parameters: []previewtypes.Parameter{ - { - ParameterData: mutable, - Value: previewtypes.StringLiteral("foo"), - Diagnostics: nil, - }, + Return(renderResult( + previewtypes.Parameter{ + ParameterData: mutable, + Value: previewtypes.StringLiteral("foo"), + Diagnostics: nil, }, - }, nil) + ), nil) render.EXPECT(). - Render(gomock.Any(), gomock.Any(), gomock.Any()). + Render(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). // Then the immutable param - Return(&preview.Output{ - Parameters: []previewtypes.Parameter{ - { - ParameterData: immutable, - // The user set the value to bar - Value: previewtypes.StringLiteral("bar"), - Diagnostics: nil, - }, + Return(renderResult( + 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, @@ -143,9 +154,9 @@ func TestResolveParameters(t *testing.T) { {name: "decreasing/decrease allowed", monotonic: "decreasing", prev: "10", cur: "5"}, {name: "decreasing/same allowed", monotonic: "decreasing", prev: "5", cur: "5"}, {name: "decreasing/increase rejected", monotonic: "decreasing", prev: "5", cur: "10", expectErr: "must be equal or lower than previous value"}, - // First build — not enforced + // First build, not enforced {name: "increasing/first build", monotonic: "increasing", cur: "1", firstBuild: true}, - // No previous value — not enforced + // No previous value, not enforced {name: "increasing/no previous", monotonic: "increasing", cur: "5"}, } @@ -159,23 +170,39 @@ func TestResolveParameters(t *testing.T) { render.EXPECT(). Render(gomock.Any(), gomock.Any(), gomock.Any()). AnyTimes(). - 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)}, - }, + 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, }, + Value: previewtypes.StringLiteral(tc.cur), + Diagnostics: nil, }, - }, 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) var previousValues []database.WorkspaceBuildParameter if tc.prev != "" { @@ -205,4 +232,172 @@ 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, + } } diff --git a/coderd/dynamicparameters/static.go b/coderd/dynamicparameters/static.go index 46682d3378..025d817a52 100644 --- a/coderd/dynamicparameters/static.go +++ b/coderd/dynamicparameters/static.go @@ -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) (*preview.Output, hcl.Diagnostics) { +func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string]string, _ ...RenderOption) (*RenderResult, hcl.Diagnostics) { params := r.staticParams for i := range params { param := ¶ms[i] @@ -52,8 +52,10 @@ func (r *staticRender) Render(_ context.Context, _ uuid.UUID, values map[string] param.Diagnostics = previewtypes.Diagnostics(param.Valid(param.Value)) } - return &preview.Output{ - Parameters: params, + return &RenderResult{ + Output: &preview.Output{ + Parameters: params, + }, }, hcl.Diagnostics{ { // Only a warning because the form does still work. diff --git a/coderd/dynamicparameters/testdata/secret_conditional/main.tf b/coderd/dynamicparameters/testdata/secret_conditional/main.tf new file mode 100644 index 0000000000..bcdc90ebed --- /dev/null +++ b/coderd/dynamicparameters/testdata/secret_conditional/main.tf @@ -0,0 +1,20 @@ +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" +} diff --git a/coderd/dynamicparameters/testdata/secret_env_and_file/main.tf b/coderd/dynamicparameters/testdata/secret_env_and_file/main.tf new file mode 100644 index 0000000000..24ee85ade3 --- /dev/null +++ b/coderd/dynamicparameters/testdata/secret_env_and_file/main.tf @@ -0,0 +1,17 @@ +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" +} diff --git a/coderd/dynamicparameters/testdata/secret_required/main.tf b/coderd/dynamicparameters/testdata/secret_required/main.tf new file mode 100644 index 0000000000..98434c5a26 --- /dev/null +++ b/coderd/dynamicparameters/testdata/secret_required/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_secret" "gh" { + env = "GITHUB_TOKEN" + help_message = "Add a GitHub PAT with env=GITHUB_TOKEN" +} diff --git a/coderd/parameters.go b/coderd/parameters.go index 00a0e0369c..1ba928d4cb 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -82,6 +82,7 @@ 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) { @@ -116,14 +117,15 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini ctx := r.Context() // Send an initial form state, computed without any user input. - result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs) + result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements()) response := codersdk.DynamicParametersResponse{ ID: 0, Diagnostics: db2sdk.HCLDiagnostics(diagnostics), } - if result != nil { - response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter) + if result.Output != nil { + response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) } + response.SecretRequirements = result.SecretRequirements httpapi.Write(ctx, rw, http.StatusOK, response) } @@ -150,14 +152,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request ) // Send an initial form state, computed without any user input. - result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs) + result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements()) response := codersdk.DynamicParametersResponse{ ID: -1, // Always start with -1. Diagnostics: db2sdk.HCLDiagnostics(diagnostics), } - if result != nil { - response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter) + if result.Output != nil { + response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) } + response.SecretRequirements = result.SecretRequirements err = stream.Send(response) if err != nil { stream.Drop() @@ -187,14 +190,15 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request ownerID = update.OwnerID - result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs) + result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs, dynamicparameters.IncludeSecretRequirements()) response := codersdk.DynamicParametersResponse{ ID: update.ID, Diagnostics: db2sdk.HCLDiagnostics(diagnostics), } - if result != nil { - response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter) + if result.Output != nil { + response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) } + response.SecretRequirements = result.SecretRequirements err = stream.Send(response) if err != nil { stream.Drop() diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 1229a61dc9..3473cc01e8 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -16,6 +16,7 @@ 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" @@ -386,6 +387,89 @@ 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) + }) + + // 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) + }) } type setupDynamicParamsTestParams struct { diff --git a/coderd/testdata/parameters/secret_required/main.tf b/coderd/testdata/parameters/secret_required/main.tf new file mode 100644 index 0000000000..98434c5a26 --- /dev/null +++ b/coderd/testdata/parameters/secret_required/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +data "coder_secret" "gh" { + env = "GITHUB_TOKEN" + help_message = "Add a GitHub PAT with env=GITHUB_TOKEN" +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 3f7b48b9ec..6c794e3cdf 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -383,6 +383,7 @@ 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 != "" { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f33af027ba..bba70502f5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -794,6 +794,7 @@ 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) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index f935c3512c..ff8d6d623f 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -17,6 +17,7 @@ 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" @@ -59,6 +60,7 @@ type Builder struct { deploymentValues *codersdk.DeploymentValues experiments codersdk.Experiments usageChecker UsageChecker + logger slog.Logger richParameterValues []codersdk.WorkspaceBuildParameter initiator uuid.UUID @@ -195,6 +197,12 @@ 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 @@ -759,6 +767,7 @@ 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) @@ -884,10 +893,16 @@ 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) + presetParameterValues, + resolveOpts...) if err != nil { return nil, nil, BuildError{http.StatusBadRequest, "resolve parameters", err} } @@ -1123,13 +1138,13 @@ func (b *Builder) getDynamicProvisionerTags() (map[string]string, error) { vals[name] = values[i] } - output, diags := render.Render(b.ctx, b.workspace.OwnerID, vals) - tagErr := dynamicparameters.CheckTags(output, diags) + result, diags := render.Render(b.ctx, b.workspace.OwnerID, vals) + tagErr := dynamicparameters.CheckTags(result.Output, diags) if tagErr != nil { return nil, BuildError{http.StatusBadRequest, "workspace tags validation failed", tagErr} } - for k, v := range output.WorkspaceTags.Tags() { + for k, v := range result.Output.WorkspaceTags.Tags() { tags[k] = v } diff --git a/coderd/wsbuilder/wsbuilder_internal_test.go b/coderd/wsbuilder/wsbuilder_internal_test.go new file mode 100644 index 0000000000..92f313b82b --- /dev/null +++ b/coderd/wsbuilder/wsbuilder_internal_test.go @@ -0,0 +1,70 @@ +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() {} diff --git a/codersdk/parameters.go b/codersdk/parameters.go index 937fbe4005..ba1ac864e9 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -170,10 +170,18 @@ type DynamicParametersRequest struct { OwnerID uuid.UUID `json:"owner_id,omitempty" format:"uuid"` } +type SecretRequirementStatus struct { + Env string `json:"env,omitempty"` + File string `json:"file,omitempty"` + HelpMessage string `json:"help_message"` + Satisfied bool `json:"satisfied"` +} + type DynamicParametersResponse struct { - ID int `json:"id"` - Diagnostics []FriendlyDiagnostic `json:"diagnostics"` - Parameters []PreviewParameter `json:"parameters"` + ID int `json:"id"` + Diagnostics []FriendlyDiagnostic `json:"diagnostics"` + Parameters []PreviewParameter `json:"parameters"` + SecretRequirements []SecretRequirementStatus `json:"secret_requirements,omitempty"` // TODO: Workspace tags } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c7ef91a142..33a5b48440 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4557,17 +4557,26 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "value": "string" } } + ], + "secret_requirements": [ + { + "env": "string", + "file": "string", + "help_message": "string", + "satisfied": true + } ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|---------------------------------------------------------------------|----------|--------------|-------------| -| `diagnostics` | array of [codersdk.FriendlyDiagnostic](#codersdkfriendlydiagnostic) | false | | | -| `id` | integer | false | | | -| `parameters` | array of [codersdk.PreviewParameter](#codersdkpreviewparameter) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|-------------------------------------------------------------------------------|----------|--------------|-------------| +| `diagnostics` | array of [codersdk.FriendlyDiagnostic](#codersdkfriendlydiagnostic) | false | | | +| `id` | integer | false | | | +| `parameters` | array of [codersdk.PreviewParameter](#codersdkpreviewparameter) | false | | | +| `secret_requirements` | array of [codersdk.SecretRequirementStatus](#codersdksecretrequirementstatus) | false | | | ## codersdk.Entitlement @@ -8885,6 +8894,26 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `ssh_config_options` | object | false | | | | » `[any property]` | string | false | | | +## codersdk.SecretRequirementStatus + +```json +{ + "env": "string", + "file": "string", + "help_message": "string", + "satisfied": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|---------|----------|--------------|-------------| +| `env` | string | false | | | +| `file` | string | false | | | +| `help_message` | string | false | | | +| `satisfied` | boolean | false | | | + ## codersdk.ServerSentEvent ```json diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index c36709bb6f..1c319bb041 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2802,6 +2802,14 @@ curl -X POST http://coder-server:8080/api/v2/templateversions/{templateversion}/ "value": "string" } } + ], + "secret_requirements": [ + { + "env": "string", + "file": "string", + "help_message": "string", + "satisfied": true + } ] } ``` diff --git a/go.mod b/go.mod index 20987ae110..f4c5d57268 100644 --- a/go.mod +++ b/go.mod @@ -504,7 +504,7 @@ require ( github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab - github.com/coder/preview v1.0.8 + github.com/coder/preview v1.0.9 github.com/danieljoos/wincred v1.2.3 github.com/dgraph-io/ristretto/v2 v2.4.0 github.com/elazarl/goproxy v1.8.0 diff --git a/go.sum b/go.sum index 7246ace75c..5374bc9af4 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7 github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/preview v1.0.8 h1:RqejfDTplczgSiNqsrQTH7g2qV0p5FGZHTkc/psWZfM= -github.com/coder/preview v1.0.8/go.mod h1:BvAfITWREXP08NIOasaAJ2hi2TWFWc6Y0CSPKEPsMzk= +github.com/coder/preview v1.0.9 h1:SmEGRBAKN+TBn8BMlfKqLqD34m/CXYnmJfiUZTxu5EA= +github.com/coder/preview v1.0.9/go.mod h1:3+ponddy+zyv07w6mU3QPaSiAQQ06l8i2aHbWBvpJhU= github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg= github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0b3913ab03..8ebdad635b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3625,6 +3625,7 @@ export interface DynamicParametersResponse { readonly id: number; readonly diagnostics: readonly FriendlyDiagnostic[]; readonly parameters: readonly PreviewParameter[]; + readonly secret_requirements?: readonly SecretRequirementStatus[]; } // From codersdk/chats.go @@ -6615,6 +6616,14 @@ export interface STUNReport { readonly Error: string | null; } +// From codersdk/parameters.go +export interface SecretRequirementStatus { + readonly env?: string; + readonly file?: string; + readonly help_message: string; + readonly satisfied: boolean; +} + // From serpent/serpent.go /** * Annotations is an arbitrary key-mapping used to extend the Option and Command types.