Merge pull request #19012 from hashicorp/f-check-permissions
backend/remote: prevent running plan or apply without permissions
This commit is contained in:
commit
979aa812df
|
@ -22,6 +22,11 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati
|
||||||
return nil, generalError("error retrieving workspace", err)
|
return nil, generalError("error retrieving workspace", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !w.Permissions.CanUpdate {
|
||||||
|
return nil, fmt.Errorf(strings.TrimSpace(
|
||||||
|
fmt.Sprintf(applyErrNoUpdateRights, b.hostname, b.organization, op.Workspace)))
|
||||||
|
}
|
||||||
|
|
||||||
if w.VCSRepo != nil {
|
if w.VCSRepo != nil {
|
||||||
return nil, fmt.Errorf(strings.TrimSpace(applyErrVCSNotSupported))
|
return nil, fmt.Errorf(strings.TrimSpace(applyErrVCSNotSupported))
|
||||||
}
|
}
|
||||||
|
@ -76,6 +81,9 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Since we already checked the permissions before creating the run
|
||||||
|
// this should never happen. But it doesn't hurt to keep this in as
|
||||||
|
// a safeguard for any unexpected situations.
|
||||||
if !r.Permissions.CanApply {
|
if !r.Permissions.CanApply {
|
||||||
// Make sure we discard the run if possible.
|
// Make sure we discard the run if possible.
|
||||||
if r.Actions.IsDiscardable {
|
if r.Actions.IsDiscardable {
|
||||||
|
@ -261,10 +269,20 @@ func (b *Remote) confirm(stopCtx context.Context, op *backend.Operation, opts *t
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyErrVCSNotSupported = `
|
const applyErrNoUpdateRights = `
|
||||||
Apply not allowed for workspaces with a VCS connection!
|
Insufficient rights to apply changes!
|
||||||
|
|
||||||
A workspace that is connected to a VCS requires the VCS based workflow
|
[reset][yellow]The provided credentials have insufficient rights to apply changes. In order
|
||||||
|
to apply changes at least write permissions on the workspace are required. To
|
||||||
|
queue a run that can be approved by someone else, please use the 'Queue Plan'
|
||||||
|
button in the web UI:
|
||||||
|
https://%s/app/%s/%s/runs[reset]
|
||||||
|
`
|
||||||
|
|
||||||
|
const applyErrVCSNotSupported = `
|
||||||
|
Apply not allowed for workspaces with a VCS connection.
|
||||||
|
|
||||||
|
A workspace that is connected to a VCS requires the VCS-driven workflow
|
||||||
to ensure that the VCS remains the single source of truth.
|
to ensure that the VCS remains the single source of truth.
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -293,10 +311,10 @@ does not require any configuration files.
|
||||||
const applyErrNoApplyRights = `
|
const applyErrNoApplyRights = `
|
||||||
Insufficient rights to approve the pending changes!
|
Insufficient rights to approve the pending changes!
|
||||||
|
|
||||||
[reset][yellow]There are pending changes, but the used credentials have insufficient rights
|
[reset][yellow]There are pending changes, but the provided credentials have insufficient rights
|
||||||
to approve them. The run will be discarded to prevent it from blocking the
|
to approve them. The run will be discarded to prevent it from blocking the queue
|
||||||
queue waiting for external approval. To trigger a run that can be approved by
|
waiting for external approval. To queue a run that can be approved by someone
|
||||||
someone else, please use the 'Queue Plan' button in the web UI:
|
else, please use the 'Queue Plan' button in the web UI:
|
||||||
https://%s/app/%s/%s/runs[reset]
|
https://%s/app/%s/%s/runs[reset]
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
@ -61,10 +61,47 @@ func TestRemote_applyBasic(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemote_applyWithoutPermissions(t *testing.T) {
|
||||||
|
b := testBackendNoDefault(t)
|
||||||
|
|
||||||
|
// Create a named workspace without permissions.
|
||||||
|
w, err := b.client.Workspaces.Create(
|
||||||
|
context.Background(),
|
||||||
|
b.organization,
|
||||||
|
tfe.WorkspaceCreateOptions{
|
||||||
|
Name: tfe.String(b.prefix + "prod"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating named workspace: %v", err)
|
||||||
|
}
|
||||||
|
w.Permissions.CanUpdate = false
|
||||||
|
|
||||||
|
mod, modCleanup := module.TestTree(t, "./test-fixtures/apply")
|
||||||
|
defer modCleanup()
|
||||||
|
|
||||||
|
op := testOperationApply()
|
||||||
|
op.Module = mod
|
||||||
|
op.Workspace = "prod"
|
||||||
|
|
||||||
|
run, err := b.Operation(context.Background(), op)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error starting operation: %v", err)
|
||||||
|
}
|
||||||
|
<-run.Done()
|
||||||
|
|
||||||
|
if run.Err == nil {
|
||||||
|
t.Fatalf("expected an apply error, got: %v", run.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(run.Err.Error(), "insufficient rights to apply changes") {
|
||||||
|
t.Fatalf("expected a permissions error, got: %v", run.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemote_applyWithVCS(t *testing.T) {
|
func TestRemote_applyWithVCS(t *testing.T) {
|
||||||
b := testBackendNoDefault(t)
|
b := testBackendNoDefault(t)
|
||||||
|
|
||||||
// Create the named workspace with a VCS.
|
// Create a named workspace with a VCS.
|
||||||
_, err := b.client.Workspaces.Create(
|
_, err := b.client.Workspaces.Create(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
b.organization,
|
b.organization,
|
||||||
|
|
|
@ -853,6 +853,10 @@ func (m *mockWorkspaces) Create(ctx context.Context, organization string, option
|
||||||
w := &tfe.Workspace{
|
w := &tfe.Workspace{
|
||||||
ID: generateID("ws-"),
|
ID: generateID("ws-"),
|
||||||
Name: *options.Name,
|
Name: *options.Name,
|
||||||
|
Permissions: &tfe.WorkspacePermissions{
|
||||||
|
CanQueueRun: true,
|
||||||
|
CanUpdate: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if options.VCSRepo != nil {
|
if options.VCSRepo != nil {
|
||||||
w.VCSRepo = &tfe.VCSRepo{}
|
w.VCSRepo = &tfe.VCSRepo{}
|
||||||
|
|
|
@ -20,6 +20,16 @@ import (
|
||||||
func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation) (*tfe.Run, error) {
|
func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation) (*tfe.Run, error) {
|
||||||
log.Printf("[INFO] backend/remote: starting Plan operation")
|
log.Printf("[INFO] backend/remote: starting Plan operation")
|
||||||
|
|
||||||
|
// Retrieve the workspace used to run this operation in.
|
||||||
|
w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generalError("error retrieving workspace", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !w.Permissions.CanQueueRun {
|
||||||
|
return nil, fmt.Errorf(strings.TrimSpace(fmt.Sprintf(planErrNoQueueRunRights)))
|
||||||
|
}
|
||||||
|
|
||||||
if op.Plan != nil {
|
if op.Plan != nil {
|
||||||
return nil, fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported))
|
return nil, fmt.Errorf(strings.TrimSpace(planErrPlanNotSupported))
|
||||||
}
|
}
|
||||||
|
@ -36,12 +46,6 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
|
||||||
return nil, fmt.Errorf(strings.TrimSpace(planErrNoConfig))
|
return nil, fmt.Errorf(strings.TrimSpace(planErrNoConfig))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve the workspace used to run this operation in.
|
|
||||||
w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace)
|
|
||||||
if err != nil {
|
|
||||||
return nil, generalError("error retrieving workspace", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.plan(stopCtx, cancelCtx, op, w)
|
return b.plan(stopCtx, cancelCtx, op, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,6 +196,13 @@ func (b *Remote) plan(stopCtx, cancelCtx context.Context, op *backend.Operation,
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const planErrNoQueueRunRights = `
|
||||||
|
Insufficient rights to generate a plan!
|
||||||
|
|
||||||
|
[reset][yellow]The provided credentials have insufficient rights to generate a plan. In order
|
||||||
|
to generate plans, at least plan permissions on the workspace are required.[reset]
|
||||||
|
`
|
||||||
|
|
||||||
const planErrPlanNotSupported = `
|
const planErrPlanNotSupported = `
|
||||||
Displaying a saved plan is currently not supported!
|
Displaying a saved plan is currently not supported!
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,12 @@ package remote
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
tfe "github.com/hashicorp/go-tfe"
|
tfe "github.com/hashicorp/go-tfe"
|
||||||
"github.com/hashicorp/terraform/backend"
|
"github.com/hashicorp/terraform/backend"
|
||||||
|
@ -44,6 +48,43 @@ func TestRemote_planBasic(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemote_planWithoutPermissions(t *testing.T) {
|
||||||
|
b := testBackendNoDefault(t)
|
||||||
|
|
||||||
|
// Create a named workspace without permissions.
|
||||||
|
w, err := b.client.Workspaces.Create(
|
||||||
|
context.Background(),
|
||||||
|
b.organization,
|
||||||
|
tfe.WorkspaceCreateOptions{
|
||||||
|
Name: tfe.String(b.prefix + "prod"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating named workspace: %v", err)
|
||||||
|
}
|
||||||
|
w.Permissions.CanQueueRun = false
|
||||||
|
|
||||||
|
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||||
|
defer modCleanup()
|
||||||
|
|
||||||
|
op := testOperationPlan()
|
||||||
|
op.Module = mod
|
||||||
|
op.Workspace = "prod"
|
||||||
|
|
||||||
|
run, err := b.Operation(context.Background(), op)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error starting operation: %v", err)
|
||||||
|
}
|
||||||
|
<-run.Done()
|
||||||
|
|
||||||
|
if run.Err == nil {
|
||||||
|
t.Fatalf("expected a plan error, got: %v", run.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(run.Err.Error(), "insufficient rights to generate a plan") {
|
||||||
|
t.Fatalf("expected a permissions error, got: %v", run.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemote_planWithPlan(t *testing.T) {
|
func TestRemote_planWithPlan(t *testing.T) {
|
||||||
b := testBackendDefault(t)
|
b := testBackendDefault(t)
|
||||||
|
|
||||||
|
@ -140,6 +181,74 @@ func TestRemote_planNoConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemote_planLockTimeout(t *testing.T) {
|
||||||
|
b := testBackendDefault(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Retrieve the workspace used to run this operation in.
|
||||||
|
w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error retrieving workspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new configuration version.
|
||||||
|
c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating configuration version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a pending run to block this run.
|
||||||
|
_, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{
|
||||||
|
ConfigurationVersion: c,
|
||||||
|
Workspace: w,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating pending run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod, modCleanup := module.TestTree(t, "./test-fixtures/plan")
|
||||||
|
defer modCleanup()
|
||||||
|
|
||||||
|
input := testInput(t, map[string]string{
|
||||||
|
"cancel": "yes",
|
||||||
|
"approve": "yes",
|
||||||
|
})
|
||||||
|
|
||||||
|
op := testOperationPlan()
|
||||||
|
op.StateLockTimeout = 5 * time.Second
|
||||||
|
op.Module = mod
|
||||||
|
op.UIIn = input
|
||||||
|
op.UIOut = b.CLI
|
||||||
|
op.Workspace = backend.DefaultStateName
|
||||||
|
|
||||||
|
_, err = b.Operation(context.Background(), op)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error starting operation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigint := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigint, syscall.SIGINT)
|
||||||
|
select {
|
||||||
|
case <-sigint:
|
||||||
|
// Stop redirecting SIGINT signals.
|
||||||
|
signal.Stop(sigint)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("expected lock timeout after 5 seconds, waited 10 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.answers) != 2 {
|
||||||
|
t.Fatalf("expected unused answers, got: %v", input.answers)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := b.CLI.(*cli.MockUi).OutputWriter.String()
|
||||||
|
if !strings.Contains(output, "Lock timeout exceeded") {
|
||||||
|
t.Fatalf("missing lock timout error in output: %s", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
|
||||||
|
t.Fatalf("unexpected plan summery in output: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemote_planDestroy(t *testing.T) {
|
func TestRemote_planDestroy(t *testing.T) {
|
||||||
b := testBackendDefault(t)
|
b := testBackendDefault(t)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue