command/logout: Add terraform logout command

Use terraform logout to remove stored credentials for a remote service
host.
This commit is contained in:
Alisdair McDiarmid 2020-02-06 11:20:16 -05:00
parent 1b1327ce45
commit 081f02971d
5 changed files with 289 additions and 0 deletions

168
command/logout.go Normal file
View File

@ -0,0 +1,168 @@
package command
import (
"fmt"
"path/filepath"
"strings"
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform/command/cliconfig"
"github.com/hashicorp/terraform/tfdiags"
)
// LogoutCommand is a Command implementation which removes stored credentials
// for a remote service host.
type LogoutCommand struct {
Meta
}
// Run implements cli.Command.
func (c *LogoutCommand) Run(args []string) int {
args, err := c.Meta.process(args, false)
if err != nil {
return 1
}
cmdFlags := c.Meta.defaultFlagSet("logout")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
return 1
}
args = cmdFlags.Args()
if len(args) > 1 {
c.Ui.Error(
"The logout command expects at most one argument: the host to log out of.")
cmdFlags.Usage()
return 1
}
var diags tfdiags.Diagnostics
givenHostname := "app.terraform.io"
if len(args) != 0 {
givenHostname = args[0]
}
hostname, err := svchost.ForComparison(givenHostname)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid hostname",
fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()),
))
c.showDiagnostics(diags)
return 1
}
// From now on, since we've validated the given hostname, we should use
// dispHostname in the UI to ensure we're presenting it in the canonical
// form, in case that helps users with debugging when things aren't
// working as expected. (Perhaps the normalization is part of the cause.)
dispHostname := hostname.ForDisplay()
creds := c.Services.CredentialsSource()
// In normal use (i.e. without test mocks/fakes) creds will be an instance
// of the command/cliconfig.CredentialsSource type, which has some extra
// methods we can use to give the user better feedback about what we're
// going to do. credsCtx will be nil if it's any other implementation,
// though.
var credsCtx *loginCredentialsContext
if c, ok := creds.(*cliconfig.CredentialsSource); ok {
filename, _ := c.CredentialsFilePath()
credsCtx = &loginCredentialsContext{
Location: c.HostCredentialsLocation(hostname),
LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user
HelperType: c.CredentialsHelperType(),
}
}
if credsCtx.Location == cliconfig.CredentialsInOtherFile {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Credentials for %s are manually configured", dispHostname),
"The \"terraform logout\" command cannot log out because credentials for this host are manually configured in a CLI configuration file.\n\nTo log out, revoke the existing credentials and remove that block from the CLI configuration.",
))
}
if diags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
// credsCtx might not be set if we're using a mock credentials source
// in a test, but it should always be set in normal use.
if credsCtx != nil {
switch credsCtx.Location {
case cliconfig.CredentialsViaHelper:
c.Ui.Output(fmt.Sprintf("Removing the stored credentials for %s from the configured\n%q credentials helper.\n", dispHostname, credsCtx.HelperType))
case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
c.Ui.Output(fmt.Sprintf("Removing the stored credentials for %s from the following file:\n %s\n", dispHostname, credsCtx.LocalFilename))
}
}
err = creds.ForgetForHost(hostname)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to remove API token",
fmt.Sprintf("Unable to remove stored API token: %s", err),
))
}
c.showDiagnostics(diags)
if diags.HasErrors() {
return 1
}
c.Ui.Output(
fmt.Sprintf(
c.Colorize().Color(strings.TrimSpace(`
[green][bold]Success![reset] [bold]Terraform has removed the stored API token for %s.[reset]
`)),
dispHostname,
) + "\n",
)
return 0
}
// Help implements cli.Command.
func (c *LogoutCommand) Help() string {
defaultFile := c.defaultOutputFile()
if defaultFile == "" {
// Because this is just for the help message and it's very unlikely
// that a user wouldn't have a functioning home directory anyway,
// we'll just use a placeholder here. The real command has some
// more complex behavior for this case. This result is not correct
// on all platforms, but given how unlikely we are to hit this case
// that seems okay.
defaultFile = "~/.terraform/credentials.tfrc.json"
}
helpText := `
Usage: terraform logout [hostname]
Removes locally-stored credentials for specified hostname.
Note: the API token is only removed from local storage, not destroyed on the
remote server, so it will remain valid until manually revoked.
If no hostname is provided, the default hostname is app.terraform.io.
%s
`
return strings.TrimSpace(helpText)
}
// Synopsis implements cli.Command.
func (c *LogoutCommand) Synopsis() string {
return "Remove locally-stored credentials for a remote host"
}
func (c *LogoutCommand) defaultOutputFile() string {
if c.CLIConfigDir == "" {
return "" // no default available
}
return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json")
}

81
command/logout_test.go Normal file
View File

@ -0,0 +1,81 @@
package command
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/mitchellh/cli"
svchost "github.com/hashicorp/terraform-svchost"
svcauth "github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/command/cliconfig"
)
func TestLogout(t *testing.T) {
workDir, err := ioutil.TempDir("", "terraform-test-command-logout")
if err != nil {
t.Fatalf("cannot create temporary directory: %s", err)
}
defer os.RemoveAll(workDir)
ui := cli.NewMockUi()
credsSrc := cliconfig.EmptyCredentialsSourceForTests(filepath.Join(workDir, "credentials.tfrc.json"))
c := &LogoutCommand{
Meta: Meta{
Ui: ui,
Services: disco.NewWithCredentialsSource(credsSrc),
},
}
testCases := []struct {
// Hostname to associate a pre-stored token
hostname string
// Command-line arguments
args []string
// true iff the token at hostname should be removed by the command
shouldRemove bool
}{
// If no command-line arguments given, should remove app.terraform.io token
{"app.terraform.io", []string{}, true},
// Can still specify app.terraform.io explicitly
{"app.terraform.io", []string{"app.terraform.io"}, true},
// Can remove tokens for other hostnames
{"tfe.example.com", []string{"tfe.example.com"}, true},
// Logout does not remove tokens for other hostnames
{"tfe.example.com", []string{"other-tfe.acme.com"}, false},
}
for _, tc := range testCases {
host := svchost.Hostname(tc.hostname)
token := svcauth.HostCredentialsToken("some-token")
err = credsSrc.StoreForHost(host, token)
if err != nil {
t.Fatalf("unexpected error storing credentials: %s", err)
}
status := c.Run(tc.args)
if status != 0 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
}
creds, err := credsSrc.ForHost(host)
if err != nil {
t.Errorf("failed to retrieve credentials: %s", err)
}
if tc.shouldRemove {
if creds != nil {
t.Errorf("wrong token %q; should have no token", creds.Token())
}
} else {
if got, want := creds.Token(), "some-token"; got != want {
t.Errorf("wrong token %q; want %q", got, want)
}
}
}
}

View File

@ -190,6 +190,12 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g
}, nil
},
"logout": func() (cli.Command, error) {
return &command.LogoutCommand{
Meta: meta,
}, nil
},
"output": func() (cli.Command, error) {
return &command.OutputCommand{
Meta: meta,

View File

@ -0,0 +1,30 @@
---
layout: "docs"
page_title: "Command: logout"
sidebar_current: "docs-commands-logout"
description: |-
The terraform logout command is used to remove credentials stored by terraform login.
---
# Command: logout
The `terraform logout` command is used to remove credentials stored by
`terraform login`. These credentials are API tokens for Terraform Cloud,
Terraform Enterprise, or any other host that offers Terraform services.
## Usage
Usage: `terraform logout [hostname]`
If you don't provide an explicit hostname, Terraform will assume you want to
log out of Terraform Cloud at `app.terraform.io`.
-> **Note:** the API token is only removed from local storage, not destroyed on
the remote server, so it will remain valid until manually revoked.
## Credentials Storage
By default, Terraform will remove the token stored in plain text in a local CLI
configuration file called `credentials.tfrc.json`. If you have configured a
[credentials helper program](cli-config.html#credentials-helpers), Terraform
will use the helper's `forget` command to remove it.

View File

@ -190,6 +190,10 @@
<a href="/docs/commands/login.html">login</a>
</li>
<li<%= sidebar_current("docs-commands-logout") %>>
<a href="/docs/commands/logout.html">logout</a>
</li>
<li<%= sidebar_current("docs-commands-output") %>>
<a href="/docs/commands/output.html">output</a>
</li>