Skip to content

Add named login profiles and directory-scoped tenant selection - #372

Open
scott-ray-wilson wants to merge 1 commit into
mainfrom
feat/named-profiles
Open

Add named login profiles and directory-scoped tenant selection#372
scott-ray-wilson wants to merge 1 commit into
mainfrom
feat/named-profiles

Conversation

@scott-ray-wilson

@scott-ray-wilson scott-ray-wilson commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Switching organizations means logging out and back in. The CLI keeps one session per account, keyed by email in the keyring, and a session token is scoped to a single organization, so a second login destroys the first. Working across tenants in parallel means exporting tokens into env vars or .env files. Community ask: Infisical/infisical#2191.

Model

A profile is one login: an account on one instance, plus the organization it uses by default. Each profile gets its own keyring entry, so sessions coexist.

The organization is a field of the profile, not part of its identity. --org / INFISICAL_ORG retarget a single command by name, slug or id, backed by an org-keyed token cache, so the switch costs one exchange and nothing after. profile set-org (aka org switch) changes the profile's default.

Selection per invocation: --profile > INFISICAL_PROFILE > bound directory > machine default. Those last three each get a verb, so all are discoverable from profile --help:

Scope Command Undo
This machine profile use <name> reassign
This terminal profile pin <name> (via eval) profile unpin
This directory tree profile bind [name] [path] profile unbind

An explicit override beats a bound directory and says so, so a binding that did not apply is explained rather than silently ignored.

Commands

profile list | current | new | use | pin | unpin | bind | unbind | set-org | delete
org list | switch
logout [--all] [--local-only]

Rationale for the less obvious calls

  • Org as a field, not identity. Reviewers of the first cut said org switch and profiles felt like unrelated features. They were: org lived inside the profile's identity, so changing it meant mutating or forking a profile. Making it a field is the kubectl context/namespace split.
  • Targeted operations never move the machine default. login --profile x, profile new, org switch from a pinned shell all leave it alone. The default is what every unpinned terminal resolves to, so moving it reaches across terminals. --use opts in explicitly.
  • Sessions still expire at JWT_AUTH_LIFETIME; renewal stays unimplemented. Unchanged from main, where the refresh path is commented out. The server rotates the refresh token on every refresh and treats a stale one as theft by revoking the session, with a 10s grace window, which several CLI processes sharing one vault entry cannot coordinate. The token is also no longer persisted, since nothing read it.
  • Logout respects shared sessions. The server keys sessions by user, IP and user agent, so profiles for one account on one machine share a session. Logout leaves a session another profile still uses intact.
  • init stops asking for an organization. It takes the profile's. The old prompt also silently re-scoped your session as a side effect.
  • MFA. profile new and profile set-org perform their own exchange and prompt. --org on an ordinary command runs inside credential resolution and cannot prompt, so it fails with a message pointing at the command that can.
  • Sub-organizations. Nested in org list, resolvable by --org, and a profile scoped to one reports Acme / Research rather than the root org it would otherwise be indistinguishable from.

Compatibility

Migration is lazy and needs no re-login: legacy loggedInUserEmail / loggedInUsers become profiles named after the account email, which is already the keyring key, so existing sessions keep working. Those fields stay synced, and a main binary still works against a config this branch wrote (verified, including with several profiles present).

Behavior changes for release notes: init no longer prompts for an organization (--org or profile set-org to cross); reset now revokes sessions server-side instead of only deleting local files; vault set clears the profile list along with the login it already cleared.

Verification

Automated script covering 20 behaviors (login and migration, targeted-login isolation, org-as-a-dimension including token-repointing, profile new with --use/--pin, the full precedence chain, pin's refusal to report false success, logout revocation including the shared-session case). Runs in a throwaway HOME, needs an account with two orgs:

verify.sh
INFISICAL_BIN=./infisical \
VERIFY_DOMAIN=https://app.infisical.com \
VERIFY_EMAIL=you@example.com VERIFY_PASSWORD='...' \
VERIFY_ORG_A=<org-id> VERIFY_ORG_B=<org-id> \
./verify.sh

Script is attached in a comment below.

Done manually against a local instance: sub-org resolution by name and slug with a seeded child org, email-MFA prompts driven through a pty for profile set-org and profile new, single-profile output diffed byte-for-byte against a main build, downgrade to main on a branch-written config, two terminals pinned to different profiles, and per-profile instance routing.

Worth a reviewer's own pass:

  • Two terminals pinned to different profiles, then change the default in a third; the pinned two must not move
  • Multi-instance: profiles on cloud and self-hosted, each command reaching its own host with no --domain
  • Browser login (only the interactive flow shows the org picker in the terminal)
  • MFA-enforced org via profile set-org, and the --org failure message
  • Upgrade from a real pre-profiles config with several accounts

🤖 Generated with Claude Code

@infisical-review-police

Copy link
Copy Markdown

💬 Discussion in Slack: #pr-review-cli-372-add-named-login-profiles-and-directory-scoped-tenant-selection

Posted by Review Police — reviews, comments, new commits, and CI failures will stream into this channel.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds named login profiles, directory-scoped profile selection, per-command organization overrides, organization token caching, and profile-aware login and logout behavior.

  • Adds profile and organization management commands with explicit selection precedence.
  • Migrates legacy login state into profile-based configuration while retaining compatibility fields.
  • Keeps credentials isolated by profile and routes profile operations to their configured Infisical instance.
  • Adds server-side session revocation while preserving sessions shared by retained profiles.

Confidence Score: 4/5

The PR is not yet safe to merge because concurrent profile-affecting commands can still overwrite each other's configuration changes.

Profile persistence continues to load, mutate, and replace the complete shared configuration without locking or merge semantics, so simultaneous terminals can lose profiles or directory bindings and subsequently resolve the wrong profile.

Files Needing Attention: packages/util/profile.go, packages/util/config.go

Important Files Changed

Filename Overview
packages/util/profile.go Implements profile migration, selection, directory binding, organization resolution, and persistence; the previously reported concurrent whole-config update remains outstanding.
packages/util/credentials.go Loads credentials by profile name, prevents explicit cross-domain token use, applies organization overrides, and intentionally leaves session renewal disabled.
packages/cmd/user.go Reworks domain updates to target one named profile and aborts before repointing when credential cleanup fails.
packages/cmd/profile.go Adds the profile command surface for creation, selection, terminal pinning, directory binding, organization defaults, and deletion.
packages/util/logout.go Adds profile-aware local cleanup and session revocation while retaining server sessions shared by other profiles.
packages/cmd/root.go Adds global profile and organization overrides and integrates them into command-wide resolution.
packages/api/api.go Adds the authenticated API operation used to revoke a server-side user session.

Reviews (5): Last reviewed commit: "feat(cli): named login profiles for mult..." | Re-trigger Greptile

Comment thread packages/cmd/user.go Outdated
Comment thread packages/util/credentials.go Outdated
Comment thread packages/cmd/user.go Outdated
@veria-ai

veria-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 6 · PR risk: 0/10

Comment thread packages/util/profile.go
Comment thread packages/cmd/root.go Outdated
Comment thread packages/cmd/profile.go Outdated
Comment thread packages/util/profile.go Outdated
Comment thread packages/util/logout.go Outdated
@scott-ray-wilson
scott-ray-wilson force-pushed the feat/named-profiles branch 2 times, most recently from 5c2f55a to cff3364 Compare August 26, 2026 18:22
@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

@greptile review

Comment thread packages/cmd/user.go Outdated
Comment thread packages/util/credentials.go
@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

Verification script referenced in the description.

Runs in a throwaway HOME, so it never touches your real config or keychain. Needs an account with two organizations. Run it from a terminal: two of the checks assert that profile pin refuses to report success when its output is not captured, which requires a TTY on stdout and skips otherwise.

INFISICAL_BIN=./infisical \
VERIFY_DOMAIN=https://app.infisical.com \
VERIFY_EMAIL=you@example.com VERIFY_PASSWORD='...' \
VERIFY_ORG_A=<org-id> VERIFY_ORG_B=<org-id> \
./verify.sh

Last run against a local instance on the current head: 21 passed, 0 failed.

verify.sh
#!/usr/bin/env bash
# Verifies the named-profiles branch against a live instance.
#
# Runs in a throwaway HOME, so your real ~/.infisical and keychain are never
# touched. Needs an account with TWO organizations.
#
#   INFISICAL_BIN=./infisical \
#   VERIFY_DOMAIN=http://localhost:8080 \
#   VERIFY_EMAIL=you@example.com VERIFY_PASSWORD='...' \
#   VERIFY_ORG_A=<org-id> VERIFY_ORG_B=<org-id> \
#   ./verify.sh
set -uo pipefail

BIN="${INFISICAL_BIN:?set INFISICAL_BIN to the built binary}"
DOMAIN="${VERIFY_DOMAIN:?set VERIFY_DOMAIN}"
EMAIL="${VERIFY_EMAIL:?set VERIFY_EMAIL}"
PASSWORD="${VERIFY_PASSWORD:?set VERIFY_PASSWORD}"
ORG_A="${VERIFY_ORG_A:?set VERIFY_ORG_A}"
ORG_B="${VERIFY_ORG_B:?set VERIFY_ORG_B}"

BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")"
SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
export INFISICAL_DISABLE_UPDATE_CHECK=1 INFISICAL_DISABLE_MIGRATION_NOTICE=1

pass=0; fail=0
cli() { HOME="$SANDBOX" "$BIN" "$@"; }
check() { # check <description> <expected> <actual>
	if [ "$2" = "$3" ]; then printf '  ok    %s\n' "$1"; pass=$((pass+1))
	else printf '  FAIL  %s (expected %q, got %q)\n' "$1" "$2" "$3"; fail=$((fail+1)); fi
}
fresh() {
	rm -rf "$SANDBOX"/.infisical "$SANDBOX"/infisical-keyring
	cli vault set file >/dev/null 2>&1
	cli login --email "$EMAIL" --password "$PASSWORD" --organization-id "$ORG_A" \
		--domain "$DOMAIN" --plain --silent >/dev/null 2>&1
}
cfg() { python3 -c "import json,sys;print(json.load(open('$SANDBOX/.infisical/infisical-config.json')).get(sys.argv[1],''))" "$1"; }

echo "== login and migration =="
fresh
check "login creates a profile named after the email" "$EMAIL" "$(cli profile current --plain 2>/dev/null)"
check "legacy loggedInUserEmail still written" "$EMAIL" "$(cfg loggedInUserEmail)"
check "activeProfile set" "$EMAIL" "$(cfg activeProfile)"

rm -rf "$SANDBOX"/.infisical/infisical-config.json
mkdir -p "$SANDBOX"/.infisical
printf '{"loggedInUserEmail":"a@x.com","LoggedInUserDomain":"%s","loggedInUsers":[{"email":"a@x.com","domain":"%s"},{"email":"b@x.com","domain":"%s"}]}' "$DOMAIN" "$DOMAIN" "$DOMAIN" > "$SANDBOX"/.infisical/infisical-config.json
check "legacy roster migrates to one profile per account" "2" "$(cli profile list 2>/dev/null | sed 1d | grep -c .)"
check "no phantom profile from the mirror" "a@x.com" "$(cfg activeProfile)"

echo "== targeted login does not move the default =="
fresh
cli login --email "$EMAIL" --password "$PASSWORD" --organization-id "$ORG_B" \
	--domain "$DOMAIN" --profile second --plain --silent >/dev/null 2>&1
check "second profile exists" "2" "$(cli profile list 2>/dev/null | sed 1d | grep -c .)"
check "default profile unchanged by --profile login" "$EMAIL" "$(cli profile current --plain 2>/dev/null)"

echo "== org is a per-command dimension =="
before_org="$(cli profile current 2>/dev/null | awk -F': ' '/^Organization:/{print $2}')"
cli --org "$ORG_B" org list >/dev/null 2>&1
after_org="$(cli profile current 2>/dev/null | awk -F': ' '/^Organization:/{print $2}')"
check "--org leaves the profile default alone" "$before_org" "$after_org"
current_org() { cli org list 2>/dev/null | awk '$1=="*"{print $2}'; }
own_org="$(current_org)"
cli --org "$ORG_B" org list >/dev/null 2>&1
check "profile still reports its own org after --org (no token repointing)" "$own_org" "$(current_org)"

echo "== profile new =="
fresh
cli profile new third --org "$ORG_B" >/dev/null 2>&1
check "profile new creates without switching" "$EMAIL" "$(cli profile current --plain 2>/dev/null)"
cli profile new fourth --org "$ORG_B" --use >/dev/null 2>&1
check "--use switches the default" "fourth" "$(cli profile current --plain 2>/dev/null)"
check "--pin emits an export on stdout" "export INFISICAL_PROFILE=third" "$(cli profile new fifth --org "$ORG_B" >/dev/null 2>&1; cli profile pin third 2>/dev/null)"

echo "== selection precedence =="
fresh
cli login --email "$EMAIL" --password "$PASSWORD" --organization-id "$ORG_B" \
	--domain "$DOMAIN" --profile second --plain --silent >/dev/null 2>&1
mkdir -p "$SANDBOX"/proj/sub
( cd "$SANDBOX"/proj && HOME="$SANDBOX" "$BIN" profile bind second >/dev/null 2>&1 )
check "bound directory wins over the default" "second" "$(cd "$SANDBOX"/proj && HOME="$SANDBOX" "$BIN" profile current --plain 2>/dev/null)"
check "subdirectory inherits the binding" "second" "$(cd "$SANDBOX"/proj/sub && HOME="$SANDBOX" "$BIN" profile current --plain 2>/dev/null)"
check "env var beats the binding" "$EMAIL" "$(cd "$SANDBOX"/proj && HOME="$SANDBOX" INFISICAL_PROFILE="$EMAIL" "$BIN" profile current --plain 2>/dev/null)"
check "flag beats the env var" "second" "$(cd "$SANDBOX"/proj && HOME="$SANDBOX" INFISICAL_PROFILE="$EMAIL" "$BIN" --profile second profile current --plain 2>/dev/null)"
( cd "$SANDBOX"/proj && HOME="$SANDBOX" "$BIN" profile unbind >/dev/null 2>&1 )
check "unbind restores the default" "$EMAIL" "$(cd "$SANDBOX"/proj && HOME="$SANDBOX" "$BIN" profile current --plain 2>/dev/null)"

echo "== pin cannot report a false success =="
if [ -t 1 ]; then
	# stdout has to stay a terminal, otherwise it looks like eval captured it.
	cli profile pin second >/dev/tty 2>/dev/null
	check "bare pin exits non-zero when not eval'd" "1" "$?"
	check "pin through eval takes effect" "second" "$(eval "$(cli profile pin second 2>/dev/null)"; cli profile current --plain 2>/dev/null)"
else
	printf '  skip  bare pin check (stdout is not a terminal)\n'
fi

echo "== logout revokes, but not a shared session =="
fresh
cli login --email "$EMAIL" --password "$PASSWORD" --organization-id "$ORG_B" \
	--domain "$DOMAIN" --profile second --plain --silent >/dev/null 2>&1
token="$(cli user get token --plain --silent 2>/dev/null)"
cli logout --profile second >/dev/null 2>&1
code="$(curl -s -o /dev/null -w '%{http_code}' "$DOMAIN/api/v1/organization" -H "Authorization: Bearer $token")"
check "session survives while another profile uses it" "200" "$code"
cli logout --all >/dev/null 2>&1
code="$(curl -s -o /dev/null -w '%{http_code}' "$DOMAIN/api/v1/organization" -H "Authorization: Bearer $token")"
check "logout --all revokes the session server-side" "revoked" "$([ "$code" = "200" ] && echo "still valid" || echo "revoked")"

printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

@veria-ai review

@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

@greptile review

@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

@greptile review

Comment thread packages/cmd/user.go Outdated
Switching organizations meant logging out and back in. The CLI stored one
session per account, keyed by email in the keyring, and a session token is
scoped to a single organization, so a second login destroyed the first.
Working across tenants in parallel meant exporting tokens into env vars or
.env files.

A profile is now one login: an account on one instance, plus the organization
it uses by default. Each profile has its own keyring entry, so sessions
coexist, and selecting a profile selects the account, instance and
organization together.

The organization is a field of the profile rather than part of its identity.
--org and INFISICAL_ORG retarget a single command by name, slug or id, and the
organization-scoped token is cached per organization in the keyring, so the
switch costs one exchange and nothing thereafter. Changing the profile's
default is `profile set-org` (also reachable as `org switch`).

Which profile a command uses is decided by --profile, then INFISICAL_PROFILE,
then a bound directory, then the machine default. An explicit override wins over
a bound directory, and says so, so that a binding which did not apply is
explained rather than silently ignored. Those last three each get
their own verb, so all of them are discoverable from `profile --help`:

  profile use <name>            the default for this machine
  profile pin <name>            this terminal only, via eval
  profile bind [name] [path]    a directory and everything under it

Sub-organizations are handled throughout: they appear nested in `org list`,
`--org` resolves them by name, slug or id, and a profile scoped to one reports
it as "Acme / Research" rather than as the root organization it would otherwise
be indistinguishable from. Organizations that require MFA prompt during
`profile new` and `profile set-org`, which perform their own exchange; `--org`
on an ordinary command cannot prompt, so it fails with a message pointing at
the command that can.

Commands added:

  profile list | current | new | use | pin | unpin | bind | unbind
          | set-org | delete
  org list | switch
  logout

Session handling. Sessions continue to expire at JWT_AUTH_LIFETIME, with expiry
sending the user back through login, unchanged from today. Renewal via the
stored refresh token stays unimplemented on purpose: the server rotates the
refresh token on every refresh and treats a stale one as theft by revoking the
session, which several CLI processes sharing one vault entry cannot coordinate
safely. The token is also no longer written to the vault, since nothing read it
and storing it only widens what a stolen vault yields. `logout` revokes
server-side, and so do `profile delete` and `reset`. Because the server keys
sessions by user, IP and user agent, several profiles for one account on one
machine share a session, so a session another profile still uses is left intact
and only local credentials are removed.

Integration with existing commands: `init` uses the profile's organization
instead of asking again and offers to bind the directory; `user switch`
operates on profiles; `vault set` clears them. An explicit --domain now beats a
profile's saved domain instead of being silently overridden, `user update
domain` only repoints profiles that were on the instance being changed rather
than every profile sharing an email, and `reset` removes every stored session
instead of orphaning all but the active one.

Hardening from review: profile names are shell-quoted where pin prints an
export, since a derived name comes from a server-supplied email and would
otherwise run as a command under eval; organization selectors match by id, then
slug, then name, with ambiguity rejected, so an organization named after
another's id cannot be selected in its place; logout authenticates revocation
with any live token rather than only the profile's own, which previously let a
cached organization token survive locally deleted credentials; a profile's
session is refused rather than sent when an explicit --domain names a different
instance; `user update domain` selects a profile rather than an account, so profiles
sharing an email and instance for different organizations are not moved
together, and the moved profile's session is cleared, before the new instance is
recorded, since a session that outlived the change would be sent there; server-supplied names are stripped
of control characters before reaching a terminal; and the legacy login pointer
is published only for email-named profiles, so an older binary cannot load one
profile's token while aimed at another's instance.

Migration is lazy and requires no re-login. Legacy loggedInUserEmail and
loggedInUsers entries become profiles named after the account email, which is
also the legacy keyring key, so existing sessions keep working untouched, and
those fields stay in sync with the active profile for older binaries and
scripts that read them. Single-profile users see no change in behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

@greptile review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant