Files
coder/coderd/dynamicparameters/render.go
Sas Swart a4f1c64a9b fix: allow dynamic parameters to consider the prebuilds user an owner (#18529)
This Pull request allows dynamic parameters to list system users in its
search for workspace owners. This is necessary to allow prebuilds to
reconcile prebuilt workspaces and to delete them.
2025-06-24 16:47:01 +02:00

339 lines
10 KiB
Go

package dynamicparameters
import (
"context"
"database/sql"
"io/fs"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"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/preview"
previewtypes "github.com/coder/preview/types"
"github.com/hashicorp/hcl/v2"
)
// 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`.
//
// '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)
Close()
}
var ErrTemplateVersionNotReady = xerrors.New("template version job not finished")
// 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
// cache of objects
templateVersion *database.TemplateVersion
job *database.ProvisionerJob
terraformValues *database.TemplateVersionTerraformValue
}
// Prepare is the entrypoint for this package. It loads the necessary objects &
// files from the database and returns a Renderer that can be used to render the
// template version's parameters.
func Prepare(ctx context.Context, db database.Store, cache files.FileAcquirer, versionID uuid.UUID, options ...func(r *loader)) (Renderer, error) {
l := &loader{
templateVersionID: versionID,
}
for _, opt := range options {
opt(l)
}
return l.Renderer(ctx, db, cache)
}
func WithTemplateVersion(tv database.TemplateVersion) func(r *loader) {
return func(r *loader) {
if tv.ID == r.templateVersionID {
r.templateVersion = &tv
}
}
}
func WithProvisionerJob(job database.ProvisionerJob) func(r *loader) {
return func(r *loader) {
r.job = &job
}
}
func WithTerraformValues(values database.TemplateVersionTerraformValue) func(r *loader) {
return func(r *loader) {
if values.TemplateVersionID == r.templateVersionID {
r.terraformValues = &values
}
}
}
func (r *loader) loadData(ctx context.Context, db database.Store) error {
if r.templateVersion == nil {
tv, err := db.GetTemplateVersionByID(ctx, r.templateVersionID)
if err != nil {
return xerrors.Errorf("template version: %w", err)
}
r.templateVersion = &tv
}
if r.job == nil {
job, err := db.GetProvisionerJobByID(ctx, r.templateVersion.JobID)
if err != nil {
return xerrors.Errorf("provisioner job: %w", err)
}
r.job = &job
}
if !r.job.CompletedAt.Valid {
return ErrTemplateVersionNotReady
}
if r.terraformValues == nil {
values, err := db.GetTemplateVersionTerraformValues(ctx, r.templateVersion.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("template version terraform values: %w", err)
}
if xerrors.Is(err, sql.ErrNoRows) {
// If the row does not exist, return zero values.
//
// Older template versions (prior to dynamic parameters) will be missing
// this row, and we can assume the 'ProvisionerdVersion' "" (unknown).
values = database.TemplateVersionTerraformValue{
TemplateVersionID: r.templateVersionID,
UpdatedAt: time.Time{},
CachedPlan: nil,
CachedModuleFiles: uuid.NullUUID{},
ProvisionerdVersion: "",
}
}
r.terraformValues = &values
}
return nil
}
// Renderer returns a Renderer that can be used to render the template version's
// parameters. It automatically determines whether to use a static or dynamic
// renderer based on the template version's state.
//
// Static parameter rendering is required to support older template versions that
// do not have the database state to support dynamic parameters. A constant
// warning will be displayed for these template versions.
func (r *loader) Renderer(ctx context.Context, db database.Store, cache files.FileAcquirer) (Renderer, error) {
err := r.loadData(ctx, db)
if err != nil {
return nil, xerrors.Errorf("load data: %w", err)
}
if !ProvisionerVersionSupportsDynamicParameters(r.terraformValues.ProvisionerdVersion) {
return r.staticRender(ctx, db)
}
return r.dynamicRenderer(ctx, db, files.NewCacheCloser(cache))
}
// Renderer caches all the necessary files when rendering a template version's
// parameters. It must be closed after use to release the cached files.
func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *files.CacheCloser) (*dynamicRenderer, error) {
closeFiles := true // If the function returns with no error, this will toggle to false.
defer func() {
if closeFiles {
cache.Close()
}
}()
// If they can read the template version, then they can read the file for
// parameter loading purposes.
//nolint:gocritic
fileCtx := dbauthz.AsFileReader(ctx)
var templateFS fs.FS
var err error
templateFS, err = cache.Acquire(fileCtx, db, r.job.FileID)
if err != nil {
return nil, xerrors.Errorf("acquire template file: %w", err)
}
var moduleFilesFS *files.CloseFS
if r.terraformValues.CachedModuleFiles.Valid {
moduleFilesFS, err = cache.Acquire(fileCtx, db, r.terraformValues.CachedModuleFiles.UUID)
if err != nil {
return nil, xerrors.Errorf("acquire module files: %w", err)
}
templateFS = files.NewOverlayFS(templateFS, []files.Overlay{{Path: ".terraform/modules", FS: moduleFilesFS}})
}
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,
}, nil
}
type dynamicRenderer struct {
db database.Store
data *loader
templateFS fs.FS
ownerErrors map[uuid.UUID]error
currentOwner *previewtypes.WorkspaceOwner
once sync.Once
close func()
}
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 {
ownerErr = r.getWorkspaceOwnerData(ctx, ownerID)
}
if ownerErr != nil || r.currentOwner == nil {
r.ownerErrors[ownerID] = ownerErr
return nil, hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Failed to fetch workspace owner",
Detail: "Please check your permissions or the user may not exist.",
Extra: previewtypes.DiagnosticExtra{
Code: "owner_not_found",
},
},
}
}
input := preview.Input{
PlanJSON: r.data.terraformValues.CachedPlan,
ParameterValues: values,
Owner: *r.currentOwner,
// 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),
}
return preview.Preview(ctx, input, r.templateFS)
}
func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uuid.UUID) error {
if r.currentOwner != nil && r.currentOwner.ID == ownerID.String() {
return nil // already fetched
}
// You only need to be able to read the organization member to get the owner
// data. Only the terraform files can therefore leak more information than the
// caller should have access to. All this info should be public assuming you can
// read the user though.
mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: r.data.templateVersion.OrganizationID,
UserID: ownerID,
IncludeSystem: true,
}))
if err != nil {
return err
}
// User data is required for the form. Org member is checked above
// nolint:gocritic
user, err := r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
if err != nil {
return xerrors.Errorf("fetch user: %w", err)
}
// nolint:gocritic // This is kind of the wrong query to use here, but it
// matches how the provisioner currently works. We should figure out
// something that needs less escalation but has the correct behavior.
row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID)
if err != nil {
return xerrors.Errorf("user roles: %w", err)
}
roles, err := row.RoleNames()
if err != nil {
return xerrors.Errorf("expand roles: %w", err)
}
ownerRoles := make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
for _, it := range roles {
if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID {
continue
}
var orgID string
if it.OrganizationID != uuid.Nil {
orgID = it.OrganizationID.String()
}
ownerRoles = append(ownerRoles, previewtypes.WorkspaceOwnerRBACRole{
Name: it.Name,
OrgID: orgID,
})
}
// The correct public key has to be sent. This will not be leaked
// unless the template leaks it.
// nolint:gocritic
key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID)
if err != nil {
return xerrors.Errorf("ssh key: %w", err)
}
// The groups need to be sent to preview. These groups are not exposed to the
// user, unless the template does it through the parameters. Regardless, we need
// the correct groups, and a user might not have read access.
// nolint:gocritic
groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
OrganizationID: r.data.templateVersion.OrganizationID,
HasMemberID: ownerID,
})
if err != nil {
return xerrors.Errorf("groups: %w", err)
}
groupNames := make([]string, 0, len(groups))
for _, it := range groups {
groupNames = append(groupNames, it.Group.Name)
}
r.currentOwner = &previewtypes.WorkspaceOwner{
ID: mem.OrganizationMember.UserID.String(),
Name: mem.Username,
FullName: mem.Name,
Email: mem.Email,
LoginType: string(user.LoginType),
RBACRoles: ownerRoles,
SSHPublicKey: key.PublicKey,
Groups: groupNames,
}
return nil
}
func (r *dynamicRenderer) Close() {
r.once.Do(r.close)
}
func ProvisionerVersionSupportsDynamicParameters(version string) bool {
major, minor, err := apiversion.Parse(version)
// If the api version is not valid or less than 1.6, we need to use the static parameters
useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6)
return !useStaticParams
}