Skip to content
Open
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,18 @@ DATUM_PROJECT=my-project datumctl get dnszones
DATUM_ORGANIZATION=my-org datumctl get projects
```

`--project` and `--organization` flags work too. For machine-to-machine auth, see `datumctl login --credentials` for the machine-account flow.
`--project` and `--organization` flags work too.

To run one command as another signed-in account without switching the active one, name its session with `--session` or `DATUM_SESSION`. The value is a session name (`email@api-host`) or an email signed in on only one endpoint. The command uses that session's last context unless you pass a scope:

```bash
datumctl get dnszones --session alice@example.com@api.staging.env.datum.net
DATUM_SESSION=alice@example.com datumctl get projects
```

This works on plugin commands too (`datumctl dns zones list --session alice@example.com@api.datum.net`): datumctl takes the flag itself and sets `DATUM_SESSION` for the plugin it runs, so the plugin's own `datumctl` calls act as the same session. A stale `DATUM_SESSION` — left over from a previous logout, or inherited this way from a plugin — only affects commands that need a session; `datumctl version`, `datumctl plugin list`, and similar commands are unaffected.

For machine-to-machine auth, see `datumctl login --credentials` for the machine-account flow.

## Agent Skills

Expand Down
8 changes: 6 additions & 2 deletions internal/authutil/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ func GetUserKeyForCurrentSession() (string, *datumconfig.Session, error) {
return "", nil, err
}

if session := cfg.ActiveSessionEntry(); session != nil && session.UserKey != "" {
session, err := cfg.ActiveSessionEntryE()
if err != nil {
return "", nil, err
}
if session != nil && session.UserKey != "" {
return session.UserKey, session, nil
}

session, err := bootstrapSessionFromKeyring(cfg)
session, err = bootstrapSessionFromKeyring(cfg)
if err != nil {
return "", nil, err
}
Expand Down
21 changes: 18 additions & 3 deletions internal/client/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,23 @@ func (c *CustomConfigFlags) loadDatumContext() (*datumconfig.DiscoveredContext,
if err := authutil.EnsureUserKeysMigrated(cfg); err != nil {
return nil, nil, err
}
ctxEntry := cfg.CurrentContextEntry()
// CurrentContextEntryE resolves a pending DATUM_SESSION override before
// consulting it, so a stale value fails here with a clear error instead of
// silently building a REST config for the real active session.
ctxEntry, err := cfg.CurrentContextEntryE()
if err != nil {
return nil, nil, err
}
if ctxEntry == nil {
// Under a session override with no context, still hand back the
// overriding session so its endpoint and TLS settings apply.
if datumconfig.HasSessionOverride() {
session, err := cfg.ActiveSessionEntryE()
if err != nil {
return nil, nil, err
}
return nil, session, nil
}
return nil, nil, nil
}
session := cfg.SessionByName(ctxEntry.Session)
Expand Down Expand Up @@ -376,8 +391,8 @@ func (c *CustomConfigFlags) ensureOnboardingComplete(
sessionName := ""
if ctxEntry != nil {
sessionName = ctxEntry.Session
} else if cfg.ActiveSession != "" {
sessionName = cfg.ActiveSession
} else {
sessionName = cfg.ActiveSessionName()
}
orgDisplayName = cfg.OrgDisplayName(sessionName, orgID)
}
Expand Down
155 changes: 155 additions & 0 deletions internal/client/session_override_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package client

import (
"context"
"encoding/json"
"strings"
"testing"
"time"

"golang.org/x/oauth2"

"go.datum.net/datumctl/internal/authutil"
"go.datum.net/datumctl/internal/datumconfig"
customerrors "go.datum.net/datumctl/internal/errors"
"go.datum.net/datumctl/internal/keyring"
"go.datum.net/datumctl/internal/miloapi"
)

const (
ovProd = "swells@datum.net@api.datum.net"
ovStaging = "swells@datum.net@api.staging.env.datum.net"
ovSolo = "solo@example.com@api.datum.net"
)

func setupFactoryOverrideEnv(t *testing.T) *DatumCloudFactory {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("DATUM_PROJECT", "")
t.Setenv("DATUM_ORGANIZATION", "")
keyring.MockInit()
t.Cleanup(datumconfig.ClearSessionOverride)

for key, subject := range map[string]string{"key-prod": "u-prod", "key-staging": "u-staging", "key-solo": "u-solo"} {
blob, _ := json.Marshal(authutil.StoredCredentials{
Hostname: "auth.datum.net",
Subject: subject,
Token: &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)},
})
if err := keyring.Set(authutil.ServiceName, key, string(blob)); err != nil {
t.Fatal(err)
}
}

prodCtx := datumconfig.QualifiedContextName(ovProd, "org-prod")
stagingCtx := datumconfig.QualifiedContextName(ovStaging, "org-staging/proj-staging")
cfg := datumconfig.NewV1Beta1()
cfg.Sessions = []datumconfig.Session{
{Name: ovProd, UserKey: "key-prod", UserEmail: "swells@datum.net",
Endpoint: datumconfig.Endpoint{Server: "https://api.datum.net"}, LastContext: prodCtx},
{Name: ovStaging, UserKey: "key-staging", UserEmail: "swells@datum.net",
Endpoint: datumconfig.Endpoint{Server: "https://api.staging.env.datum.net", TLSServerName: "staging.sni"},
LastContext: stagingCtx},
{Name: ovSolo, UserKey: "key-solo", UserEmail: "solo@example.com",
Endpoint: datumconfig.Endpoint{Server: "https://api.solo.example", TLSServerName: "solo.sni"}},
}
cfg.Contexts = []datumconfig.DiscoveredContext{
{Name: prodCtx, Session: ovProd, OrganizationID: "org-prod"},
{Name: stagingCtx, Session: ovStaging, OrganizationID: "org-staging", ProjectID: "proj-staging"},
}
cfg.ActiveSession = ovProd
cfg.CurrentContext = prodCtx
if err := datumconfig.SaveV1Beta1(cfg); err != nil {
t.Fatal(err)
}

f, err := NewDatumFactory(context.Background())
if err != nil {
t.Fatal(err)
}
f.ConfigFlags.SkipOnboardingCheck = true
return f
}

// Requests go to the overriding session's endpoint with its credentials and
// its last context, while --project and DATUM_ORGANIZATION still pick scope.
func TestToRESTConfig_SessionOverride(t *testing.T) {
const staging = "https://api.staging.env.datum.net"
tests := []struct {
name string
override string
project string
envOrg string
wantHost string
wantServer string
}{
{name: "no override", wantHost: miloapi.OrgControlPlaneURL("https://api.datum.net", "org-prod")},
{name: "override uses its last context", override: ovStaging,
wantHost: miloapi.ProjectControlPlaneURL(staging, "proj-staging"), wantServer: "staging.sni"},
{name: "--project beats the override's context", override: ovStaging, project: "other",
wantHost: miloapi.ProjectControlPlaneURL(staging, "other"), wantServer: "staging.sni"},
{name: "DATUM_ORGANIZATION beats the override's context", override: ovStaging, envOrg: "env-org",
wantHost: miloapi.OrgControlPlaneURL(staging, "env-org"), wantServer: "staging.sni"},
{name: "override without a context uses its user control plane on its endpoint", override: ovSolo,
wantHost: miloapi.UserControlPlaneURL("https://api.solo.example", "u-solo"), wantServer: "solo.sni"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := setupFactoryOverrideEnv(t)
t.Setenv("DATUM_ORGANIZATION", tt.envOrg)
*f.ConfigFlags.Project = tt.project
if tt.override != "" {
datumconfig.SetSessionOverride(tt.override, datumconfig.SessionOverrideFromFlag)
}
rc, err := f.ConfigFlags.ToRESTConfig()
if err != nil {
t.Fatalf("ToRESTConfig: %v", err)
}
if rc.Host != tt.wantHost {
t.Errorf("Host = %q, want %q", rc.Host, tt.wantHost)
}
if rc.ServerName != tt.wantServer {
t.Errorf("ServerName = %q, want %q", rc.ServerName, tt.wantServer)
}
})
}
}

// A DATUM_SESSION that names no session must fail ToRESTConfig with the same
// clear, list-of-choices error a bad --session gives — not silently build a
// REST config for the real active session.
func TestToRESTConfig_StaleEnvSessionOverride(t *testing.T) {
f := setupFactoryOverrideEnv(t)
datumconfig.SetPendingSessionOverride("nobody@example.com")

_, err := f.ConfigFlags.ToRESTConfig()
if err == nil {
t.Fatal("expected an error")
}
if _, ok := customerrors.IsUserError(err); !ok {
t.Errorf("error is not a UserError: %v", err)
}
for _, want := range []string{"No session matches DATUM_SESSION nobody@example.com.", ovProd, ovStaging, ovSolo} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error missing %q:\n%s", want, err)
}
}
}

func TestResolveSessionEndpoint_SessionOverride(t *testing.T) {
setupFactoryOverrideEnv(t)
datumconfig.SetSessionOverride(ovStaging, datumconfig.SessionOverrideFromEnv)
cfg, err := datumconfig.LoadAuto()
if err != nil {
t.Fatal(err)
}
session, ep, err := ResolveSessionEndpoint(cfg, "")
if err != nil {
t.Fatal(err)
}
if session.Name != ovStaging || ep.BaseServer != "https://api.staging.env.datum.net" || ep.UserKey != "key-staging" {
t.Errorf("got session %q, server %q, key %q; want the staging session", session.Name, ep.BaseServer, ep.UserKey)
}
}
2 changes: 1 addition & 1 deletion internal/cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Advanced — kubectl integration:
}

cmd.AddCommand(
getTokenCmd,
getTokenCmd(),
listCmd,
switchCmd(),
updateKubeconfigCmd(),
Expand Down
30 changes: 18 additions & 12 deletions internal/cmd/auth/get_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ const (
)

// getTokenCmd retrieves tokens based on the --output flag.
var getTokenCmd = &cobra.Command{
Use: "get-token",
Short: "Print an access token (kubectl and plugin credential helper)",
Long: `Print the current access token for the active Datum Cloud user.
func getTokenCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "get-token",
Short: "Print an access token (kubectl and plugin credential helper)",
Long: `Print the current access token for the active Datum Cloud user.

Most datumctl users do not need this command — datumctl handles
authentication automatically for all its own commands.
Expand All @@ -43,26 +44,31 @@ This command exists for two advanced use cases:
$DATUM_CREDENTIALS_HELPER auth get-token --session $DATUM_SESSION
When DATUM_SESSION is empty, omit the --session flag.

Without --session, the token is for the active session, or for the session
named by DATUM_SESSION when it is set.

If the stored token is expired, datumctl automatically uses the stored
refresh token to obtain a new one before printing.

Output formats (--output / -o):
token Print the raw access token (default).
client.authentication.k8s.io/v1 Print a Kubernetes ExecCredential JSON
object for kubectl credential plugin use.`,
Example: ` # Get a raw token for use in a script or direct API call
Example: ` # Get a raw token for use in a script or direct API call
datumctl auth get-token

# Get a Kubernetes ExecCredential JSON object (used by kubectl automatically)
datumctl auth get-token --output=client.authentication.k8s.io/v1`,
Args: cobra.NoArgs,
RunE: runGetToken, // Use single function
}
Args: cobra.NoArgs,
RunE: runGetToken, // Use single function
}

func init() {
// Add flags for direct execution mode
getTokenCmd.Flags().StringP("output", "o", outputFormatToken, fmt.Sprintf("Output format. One of: %s|%s", outputFormatToken, outputFormatK8sV1Creds))
getTokenCmd.Flags().String("session", "", "Look up a specific session by name (defaults to the active session). Used by the kubectl exec plugin path so each kubeconfig entry pins to its own datumctl session.")
cmd.Flags().StringP("output", "o", outputFormatToken, fmt.Sprintf("Output format. One of: %s|%s", outputFormatToken, outputFormatK8sV1Creds))
// This local --session shadows the global one on purpose: kubeconfig exec
// entries written by update-kubeconfig pass an exact session name here, and
// its lookup and errors must not change under them.
cmd.Flags().String("session", "", "Look up a specific session by name (defaults to the active session). Used by the kubectl exec plugin path so each kubeconfig entry pins to its own datumctl session.")
return cmd
}

// runGetToken implements the logic based on the --output flag.
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/auth/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func runList(_ *cobra.Command, _ []string) error {

for _, s := range cfg.Sessions {
status := ""
if s.Name == cfg.ActiveSession {
if s.Name == cfg.ActiveSessionName() {
status = "Active"
}
if showEndpoint {
Expand Down
9 changes: 8 additions & 1 deletion internal/cmd/auth/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ func switchCmd() *cobra.Command {
without one, datumctl prints the command to run for each match.

Each session remembers the last context you used, so switching users
also restores the context. To add a new account, run 'datumctl login'.`),
also restores the context. To add a new account, run 'datumctl login'.

This command changes the active session, so it rejects the global
--session flag and ignores DATUM_SESSION. To run a single command as
another session without switching, pass --session to that command.`),
Annotations: map[string]string{
datumconfig.SessionOverrideAnnotation: datumconfig.SessionOverrideRejected,
},
Example: templates.Examples(`
# Interactive session picker
datumctl auth switch
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/ctx/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,15 @@ func printContextTree(w io.Writer, cfg *datumconfig.ConfigV1Beta1, sessionName s

if g.orgCtx != nil {
current := ""
if cfg.CurrentContext == g.orgCtx.Name {
if cfg.CurrentContextName() == g.orgCtx.Name {
current = "*"
}
tbl.AddRow(cfg.OrgDisplayName(sessionName, orgID), orgID, "org", current)
}

for _, p := range g.projects {
current := ""
if cfg.CurrentContext == p.Name {
if cfg.CurrentContextName() == p.Name {
current = "*"
}
tbl.AddRow(" "+cfg.ProjectDisplayName(sessionName, p.ProjectID), p.Ref(), "project", current)
Expand Down
9 changes: 8 additions & 1 deletion internal/cmd/ctx/use.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ func useCmd() *cobra.Command {
Long: `Switch the active context to an organization or project.

If no argument is provided, an interactive picker is shown.
Use the format 'org/project' to select a project context, or just 'org' for an org context.`,
Use the format 'org/project' to select a project context, or just 'org' for an org context.

Switching context can change the active session, so this command rejects the
global --session flag and ignores DATUM_SESSION. To use another session's
context for one command, pass --session to that command instead.`,
Annotations: map[string]string{
datumconfig.SessionOverrideAnnotation: datumconfig.SessionOverrideRejected,
},
Args: cobra.MaximumNArgs(1),
RunE: runUse,
}
Expand Down
12 changes: 11 additions & 1 deletion internal/cmd/landing.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ func runLanding(cmd *cobra.Command, _ []string) {
return
}

session := cfg.ActiveSessionEntry()
session, err := cfg.ActiveSessionEntryE()
if err != nil {
// A stale DATUM_SESSION names no session. The landing page is a
// read-only "what's my state" view, so render it against the real
// active session rather than hard-failing a command with no
// subcommand to retry — but say so, since it's why the "Session"
// line below won't match DATUM_SESSION.
fmt.Fprintf(out, "Note: DATUM_SESSION matches no signed-in session; showing the active session instead.\n\n")
session = cfg.ActiveSessionEntry()
}
if session == nil {
printLoggedOutLanding(out)
return
Expand Down Expand Up @@ -337,6 +346,7 @@ var landingTips = []string{
"'datumctl plugin index add <name> <url>' registers a team or community catalog.",
// Power-user tips
"Set DATUM_PROJECT or DATUM_ORGANIZATION to override context for a single command.",
"Pass --session or set DATUM_SESSION to run a command as another signed-in account without switching.",
"'datumctl describe <resource> <name>' shows status conditions — handy for debugging.",
"JSON and YAML both work with -f; mix them freely in a single directory.",
"'datumctl version --client' prints the local version without hitting the server.",
Expand Down
8 changes: 7 additions & 1 deletion internal/cmd/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ By default, opens your browser for OAuth2 PKCE authentication. Use
--no-browser in headless environments (SSH, CI, containers) to authenticate
via a device-code flow that does not need a browser on this machine.

Use --credentials to authenticate as a service account (non-interactive).`,
Use --credentials to authenticate as a service account (non-interactive).

Login makes the new session active, so it rejects the global --session flag
and ignores DATUM_SESSION.`,
Annotations: map[string]string{
datumconfig.SessionOverrideAnnotation: datumconfig.SessionOverrideRejected,
},
Example: ` # Log in (opens browser, then picks a context)
datumctl login

Expand Down
Loading
Loading