Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,40 @@ so `koc baremetal driver show ipmi --os-system-scope all` works from a shell
that already has a project-scoped openrc sourced. `all` is the only value
Keystone defines.

#### Where the password comes from

`--os-password` / `OS_PASSWORD` is the usual answer, but neither is a good place
for a secret: a flag value is visible in `ps` and lands in the shell history, and
an environment variable is inherited by every child process. Two more ways in:

| Source | Use it for |
| --- | --- |
| `--os-password-stdin` | scripts and CI — `koc … --os-password-stdin < secret` |
| the interactive prompt | a shell session, and a `clouds.yaml` entry that deliberately stores no password |

`--os-password-stdin` follows `docker login --password-stdin`: `koc` reads
standard input, strips one trailing line ending, and uses the rest verbatim
(leading and trailing spaces included — only the newline goes). More than one
line is an error, since that is a whole openrc piped in by mistake rather than a
password. It conflicts with an explicitly typed `--os-password` and with
`--creds-from-ns` / `--creds-from-vault`, which bring their own credentials; it
overrides `OS_PASSWORD`, which is background configuration, and it outranks a
named cloud's stored password the same way a typed `--os-password` does.

```sh
koc server list --os-password-stdin < ~/.config/koc/password
pass show keystack/admin | koc server list --os-cloud keystack --os-password-stdin
```

When nothing supplies a password and the run is interactive, `koc` asks for it on
the terminal without echo, the way `python-openstackclient` does — including for
a named cloud whose `clouds.yaml` entry has a `username` but no `password`. A
non-interactive run is never prompted: it fails with `no credentials found`
instead of blocking on a pipe nobody is going to write to. Neither source is
consulted when the request authenticates without a password (application
credentials, a pre-issued token), and `--os-password-stdin` is not combined with
a prompt — stdin has already been spent.

#### Alternative credential sources

Two koc-specific, mutually exclusive flags source credentials outside the normal
Expand Down
20 changes: 14 additions & 6 deletions docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,12 +458,20 @@ so the gap is upstream's, not the cloud's. The write side is already reachable:
`nova clear-password` is `koc server set --no-password`, and changing the
password is `koc server set --password`.

One **global flag** is koc-native too, and is deliberately not in the counts
above (the tables measure commands, not flags): `--timeout` / `OS_TIMEOUT` caps a
whole HTTP request/response exchange on every client `koc` builds. OSC has no
global equivalent — keystoneauth carries a session `timeout`, but
`python-openstackclient` registers no flag for it, so an operator's only recourse
upstream is `clouds.yaml`. See README "Timeouts" for the semantics.
Two **global flags** are koc-native too, and are deliberately not in the counts
above (the tables measure commands, not flags):

- `--timeout` / `OS_TIMEOUT` caps a whole HTTP request/response exchange on every
client `koc` builds. OSC has no global equivalent — keystoneauth carries a
session `timeout`, but `python-openstackclient` registers no flag for it, so an
operator's only recourse upstream is `clouds.yaml`. See README "Timeouts" for
the semantics.
- `--os-password-stdin` reads the password from standard input. Upstream has no
equivalent: osc-lib's only non-`OS_PASSWORD` route is the interactive `getpass`
prompt, which a CI job cannot use, leaving `--os-password` (visible in `ps` and
the shell history) or an exported `OS_PASSWORD` (inherited by every child
process). `koc` keeps that prompt — it is the parity half of the same change —
and adds the pipe. See README "Where the password comes from".

`port list --all-projects` is koc-native for the same reason and likewise not
counted: neutron has no cross-project switch, because an admin token already
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/credsfrom.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ func (o *Options) applyOpenrcVars(kv map[string]string) {
set("os-auth-url", &o.AuthURL, "OS_AUTH_URL")
set("os-username", &o.Username, "OS_USERNAME")
set("os-user-id", &o.UserID, "OS_USER_ID")
set("os-password", &o.Password, "OS_PASSWORD")
set(flagOSPassword, &o.Password, "OS_PASSWORD")
set(flagOSProjectName, &o.ProjectName, "OS_PROJECT_NAME", "OS_TENANT_NAME")
set(flagOSProjectID, &o.ProjectID, "OS_PROJECT_ID", "OS_TENANT_ID")
set("os-project-domain-name", &o.ProjectDomainName, "OS_PROJECT_DOMAIN_NAME")
Expand Down
1 change: 1 addition & 0 deletions internal/auth/flagnames.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ package auth
const (
flagOSProjectName = "os-project-name"
flagOSProjectID = "os-project-id"
flagOSPassword = "os-password"
)
22 changes: 21 additions & 1 deletion internal/auth/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
// 4. Application credentials (OS_APPLICATION_CREDENTIAL_ID / _SECRET),
// which are honored through either of the two paths above.
//
// The password has two further sources, both in password.go and neither a
// separate precedence tier: --os-password-stdin (koc-native) reads it from
// standard input instead of a flag or the environment, and a run that reaches
// authentication without one is prompted on the terminal, as osc-lib does.
//
// Naming a cloud selects it wholesale: because every auth flag defaults to its
// OS_* variable, a sourced openrc would otherwise override the named cloud
// field by field and silently send the command — credentials included — to the
Expand All @@ -26,6 +31,7 @@ package auth
import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -65,6 +71,7 @@ type Options struct {
Username string
UserID string
Password string
PasswordStdin bool
ProjectName string
ProjectID string
ProjectDomainName string
Expand Down Expand Up @@ -161,6 +168,12 @@ type Options struct {
// see testhooks.go for why the hook is here rather than in an
// export_test.go.
authenticate func(context.Context) (*Client, error)

// passwordStdinSrc and promptPassword seam the two terminal password
// sources (see password.go). Nil in every non-test build, meaning the real
// os.Stdin and an unechoed read from it.
passwordStdinSrc io.Reader
promptPassword func(io.Writer) (string, error)
}

// markForced records that flag's value was supplied by a source pflag cannot
Expand Down Expand Up @@ -234,8 +247,15 @@ func (o *Options) AddFlags(fs *pflag.FlagSet) {
"username (env OS_USERNAME)")
fs.StringVar(&o.UserID, "os-user-id", os.Getenv("OS_USER_ID"),
"user ID (env OS_USER_ID)")
fs.StringVar(&o.Password, "os-password", os.Getenv("OS_PASSWORD"),
fs.StringVar(&o.Password, flagOSPassword, os.Getenv("OS_PASSWORD"),
"password (env OS_PASSWORD)")
// UNVERIFIED against KeyStack: koc-native, python-openstackclient has no
// equivalent (it only prompts). Deliberately flag-only — reading a secret
// from stdin is a thing a run must opt into visibly, not something an
// exported variable can turn on under a command that wanted stdin for
// something else.
fs.BoolVar(&o.PasswordStdin, flagOSPasswordStdin, false,
"read the password from standard input instead of --os-password, which is visible in ps and the shell history")
fs.StringVar(&o.ProjectName, flagOSProjectName, os.Getenv("OS_PROJECT_NAME"),
"project name (env OS_PROJECT_NAME)")
fs.StringVar(&o.ProjectID, flagOSProjectID, os.Getenv("OS_PROJECT_ID"),
Expand Down
181 changes: 181 additions & 0 deletions internal/auth/password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package auth

import (
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/gophercloud/gophercloud/v2"
"golang.org/x/term"
)

// Two ways to supply the Keystone password other than --os-password /
// OS_PASSWORD, both of which exist because that pair is a poor place for a
// secret: a flag value is visible in `ps` and lands in the shell history, and
// an environment variable is inherited by every child process.
//
// - --os-password-stdin reads the password from standard input. It is
// koc-native — python-openstackclient has no equivalent — and follows
// `docker login --password-stdin`: koc reads stdin, strips one trailing
// line ending, and uses the rest verbatim.
// - The interactive prompt is python-openstackclient parity. osc-lib asks on
// the terminal (getpass) when the chosen auth type needs a password and
// nothing supplied one, rather than failing; koc now does the same.
//
// koc prompts only when stdin is a terminal. A non-interactive run must fail
// with the usual "no credentials found" error instead of blocking forever on a
// pipe nobody is going to write to.

const flagOSPasswordStdin = "os-password-stdin"

// passwordPrompt matches what osc-lib writes, so an operator moving between the
// two clients sees the same line. It goes to stderr: stdout may be a redirected
// -f json document.
const passwordPrompt = "Password: "

// applyPasswordStdin consumes stdin into o.Password when --os-password-stdin is
// set. It is called before any credential is used, so a conflicting source is
// reported before the first network round trip.
func (o *Options) applyPasswordStdin() error {
if !o.PasswordStdin || o.forced[flagOSPassword] {
// Already read. Authenticate can run more than once in a process, and
// stdin can only be consumed once, so the first read stands.
return nil
}
switch {
case o.Password != "" && o.explicitlySet(flagOSPassword):
return fmt.Errorf("--%s and --os-password are mutually exclusive", flagOSPasswordStdin)
case o.CredsFromNS != "":
return fmt.Errorf("--%s cannot be combined with --creds-from-ns, which brings its own credentials", flagOSPasswordStdin)
case o.CredsFromVault != "":
return fmt.Errorf("--%s cannot be combined with --creds-from-vault, which brings its own credentials", flagOSPasswordStdin)
}

pw, err := readPasswordStdin(o.stdin())
if err != nil {
return err
}
o.rememberPassword(pw)
return nil
}

// readPasswordStdin takes the whole of r as the password, less one trailing
// line ending.
func readPasswordStdin(r io.Reader) (string, error) {
raw, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("--%s: reading stdin: %w", flagOSPasswordStdin, err)
}
// Only the line ending goes: a password may legitimately begin or end with
// a space, and `echo`, a here-doc and every text editor append a newline.
pw := strings.TrimSuffix(string(raw), "\n")
pw = strings.TrimSuffix(pw, "\r")

switch {
case pw == "":
return "", fmt.Errorf("--%s: no password on stdin", flagOSPasswordStdin)
case strings.ContainsAny(pw, "\r\n"):
// Almost always a whole openrc or secrets file piped in by mistake.
// Authenticating with the first line and failing is the confusing
// outcome; say what happened instead.
return "", fmt.Errorf("--%s: stdin holds more than one line, so it is not just a password", flagOSPasswordStdin)
}
return pw, nil
}

// stdin is the reader --os-password-stdin consumes, seamed for tests.
func (o *Options) stdin() io.Reader {
if o.passwordStdinSrc != nil {
return o.passwordStdinSrc
}
return os.Stdin
}

// promptMissingPassword asks for the password on the terminal when the resolved
// auth options need one and no source produced it. With no terminal to ask on
// it changes nothing: the caller's own "no credentials" error is the better
// message, and gophercloud rejects the request either way.
func (o *Options) promptMissingPassword(ao *gophercloud.AuthOptions, w io.Writer) error {
if !needsPassword(ao) {
return nil
}
ask := o.terminalPassword()
if ask == nil {
return nil
}
pw, err := ask(w)
if err != nil {
return err
}
if pw == "" {
return errors.New("no password given at the prompt")
}
ao.Password = pw
o.rememberPassword(pw)
return nil
}

// rememberPassword records a password that reached koc from stdin or the
// terminal as though --os-password had carried it — it is as deliberate as one
// typed on the command line, so a named cloud's stored password must not
// outrank it, and a second authentication in the same process
// must not ask for it again (gophercloud's own reauth replays the auth options
// and never gets here).
func (o *Options) rememberPassword(pw string) {
o.Password = pw
o.markForced(flagOSPassword)
}

// needsPassword reports whether ao describes password authentication for a
// named user with the password still missing. Application credentials and a
// pre-issued token authenticate without one, so neither is prompted for.
func needsPassword(ao *gophercloud.AuthOptions) bool {
if ao.Password != "" || ao.TokenID != "" {
return false
}
if ao.ApplicationCredentialID != "" || ao.ApplicationCredentialName != "" {
return false
}
return ao.Username != "" || ao.UserID != ""
}

// willPromptForPassword reports whether a missing password will be asked for
// rather than rejected, for the env path's up-front credential check — which
// runs before the auth options exist and so tests o's own fields.
func (o *Options) willPromptForPassword() bool {
if o.Username == "" && o.UserID == "" {
return false
}
return o.terminalPassword() != nil
}

// terminalPassword returns the function that asks for a password, or nil when
// there is no terminal to ask on.
func (o *Options) terminalPassword() func(io.Writer) (string, error) {
if o.promptPassword != nil {
return o.promptPassword
}
if o.PasswordStdin || !term.IsTerminal(int(os.Stdin.Fd())) {
return nil
}
return readTerminalPassword
}

// readTerminalPassword prompts on w and reads stdin without echo.
func readTerminalPassword(w io.Writer) (string, error) {
if _, err := fmt.Fprint(w, passwordPrompt); err != nil {
return "", err
}
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
// The Enter the operator typed was swallowed with the echo, so the next
// thing written to the terminal would otherwise land on the prompt line.
if _, perr := fmt.Fprintln(w); perr != nil && err == nil {
err = perr
}
if err != nil {
return "", fmt.Errorf("reading the password: %w", err)
}
return string(pw), nil
}
Loading