Add named login profiles and directory-scoped tenant selection - #372
Add named login profiles and directory-scoped tenant selection#372scott-ray-wilson wants to merge 6 commits into
Conversation
Switching tenants previously required a full re-login because the CLI held exactly one session per email (keyring key = email) and the session JWT is scoped to a single organization. Working across tenants in parallel meant exporting tokens into .env files. Sessions are now stored as named profiles (account + instance + org), each with its own keyring entry. Selection precedence per invocation: --profile flag > INFISICAL_PROFILE env var > directory scope > global default, so parallel terminals can pin different tenants and directories can be bound to the tenant they belong to. New commands: - infisical profile list/current/use/unlink/delete (profile use --scope binds a directory tree to a profile) - infisical org list / org switch (re-scopes the session via select-organization without re-authenticating; --save-as stores the result as a new profile) Also: - infisical init persists the org re-scope on the resolved profile and offers a directory binding when multiple profiles exist - expired sessions now renew via the stored refresh token when present (previously dead code), falling back to interactive login - an explicit --domain/INFISICAL_DOMAIN now beats the saved login domain instead of being silently overridden - infisical reset removes all profile keyring entries instead of only the active one - infisical user switch operates on profiles (behavior preserved) Migration is lazy and transparent: legacy config fields become profiles named after the account email, which is also the legacy keyring key, so existing sessions keep working without re-login. Legacy fields stay synced with the active profile for older binaries and scripts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
💬 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. |
|
| Filename | Overview |
|---|---|
| packages/util/profile.go | Implements profile migration, resolution, naming, persistence, and directory scope handling, but whole-file persistence can lose concurrent updates. |
| packages/util/credentials.go | Loads profile-keyed credentials and adds automatic renewal, but the renewal path is unreachable for normal SRP sessions because login does not persist their refresh token. |
| packages/cmd/user.go | Adapts legacy user commands to profiles, but domain updates collapse the instance boundary across all profiles sharing an email. |
| packages/cmd/profile.go | Adds profile list, current, use, unlink, and delete commands; its mutations participate in unlocked shared-config updates. |
| packages/cmd/org.go | Adds organization listing and token re-scoping, including MFA and optional profile creation. |
| packages/cmd/root.go | Adds profile override resolution and notices while preserving explicit domain precedence. |
| packages/cmd/login.go | Persists logins as named profiles but leaves refresh tokens returned by SRP and MFA authentication out of stored credentials. |
| packages/util/profile_test.go | Covers migration, resolution precedence, naming, active-profile synchronization, deletion, and validation, but not concurrent persistence or refresh integration. |
Comments Outside Diff (1)
-
packages/util/profile.go, line 3411-3423 (link)Concurrent profile writes lose state
If two terminals perform profile-affecting operations concurrently, each rewrites the complete config from its own stale snapshot without locking or merge semantics. The later write drops profiles or directory bindings created by the other terminal, leaving keyring sessions unreachable or causing commands to resolve the wrong profile.
Knowledge Base Used: Core Shared Packages
Reviews (1): Last reviewed commit: "Add named login profiles and directory-s..." | Re-trigger Greptile
| // keep profile entries for this account in sync with the new domain | ||
| for idx := range configFile.Profiles { | ||
| if configFile.Profiles[idx].Email == profile { | ||
| configFile.Profiles[idx].Domain = domain | ||
| } | ||
| } |
There was a problem hiding this comment.
Domain updates cross profile boundaries
When the same email has profiles on different Infisical instances, this loop rewrites every matching profile's domain. Loading an unrelated profile then sends its profile-specific bearer token to the newly selected server, causing authentication failure or disclosing the token to the wrong instance.
How this was verified: Profile loading retrieves credentials by profile name while using the rewritten Profile.Domain as the API base URL.
Context Used: Flag SSRF risks (source)
Knowledge Base Used: Core Shared Packages
| // The session expired: try the stored refresh token before falling back to | ||
| // an interactive re-login. Only on setConfigVariables paths, so read-only | ||
| // probes (e.g. the root pre-run warning) never mutate the keyring, and so | ||
| // the refresh request targets the profile's own domain (set above). | ||
| if !isAuthenticated && setConfigVariables && userCreds.RefreshToken != "" { |
There was a problem hiding this comment.
Refresh path lacks stored tokens
When an email/SRP session expires, this guard skips automatic renewal because the login and MFA responses capture the jid refresh token but the login flow never copies it into the persisted UserCredentials. These sessions therefore fall back to interactive login at every access-token expiry.
Knowledge Base Used: CLI Authentication: Login, Token Storage, and Consumption
PR overviewThis pull request adds named CLI login profiles and directory-scoped tenant selection, including commands and output for selecting and pinning profiles and organizations. Three security issues remain open, while one has already been addressed. The most significant issue allows a compromised server to inject shell commands into the documented profile-pinning workflow when a user evaluates generated output. The remaining issues could expose a token through legacy profile-state confusion or allow backend-controlled organization names to manipulate terminal output. Open issues (3)
Fixed/addressed: 1 · PR risk: 7/10 |
…es, wire SRP refresh token Three fixes from PR review and CI root-causing: 1. Derived profile names (raw emails) are no longer validated in PersistLoginProfile. The name pattern rejected characters like '+' that are legal in emails, which made login fail after successful authentication for plus-addressed accounts and hung the CI pty harness (init auto-triggered an interactive login whose prompts the harness does not answer). Validation now applies only to user-typed names (--profile, --save-as), and the allowed charset includes '+' so email-named profiles can be targeted explicitly. 2. `user update domain` only repoints profiles whose domain matched the roster entry's previous domain. The same email can be a different account on another instance, and its session token must never be sent to the new domain. (greptile/veria review finding) 3. The password/SRP login path now stores the refresh session scraped from the `jid` cookie (login2/MFA responses, and login v3 which now scrapes it too), so expired sessions renew silently instead of always falling back to interactive re-login. (greptile review finding) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the review findings and root-caused the CI failures in c0a430e: CI: Go CLI Tests hang (caused by this PR, now fixed). The harness drives greptile/veria on greptile on the refresh path (valid): the password/SRP login path now stores the refresh session scraped from the Other failing checks, not related to this PR:
🤖 Generated with Claude Code |
|
|
||
| func syncLegacyLoginFields(configFile *models.ConfigFile, profile models.Profile) { | ||
| configFile.LoggedInUserEmail = profile.Email | ||
| configFile.LoggedInUserDomain = profile.Domain |
There was a problem hiding this comment.
Low: Legacy profile state can route credentials to another instance
For a named profile, credentials are stored under profile.Name, while older CLI versions load the keyring entry identified by LoggedInUserEmail. If an email-named profile for instance A already exists and a differently named profile for the same email on instance B becomes active, these fields point the older CLI at B while it loads A's token; an operator controlling B can capture that token. Only publish legacy login fields when the profile's credentials are actually available under the legacy email key, or clear the legacy selection so older clients require a fresh login.
`login --profile <name>` (or with INFISICAL_PROFILE set) is a scoped write to that profile. Making it the global default yanked every unpinned terminal onto the new tenant, which is exactly the cross-terminal interference profiles exist to prevent, and it made expired-session renewals (which re-exec login with --profile) steal the default as a side effect. Explicitly targeted logins now only create/update their profile, in line with the source-aware rule org switch and init already follow. Untargeted logins keep the familiar last-login-wins behavior, and a targeted login prints which profile remains the default and how to switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
One more behavior fix from demo testing: 🤖 Generated with Claude Code |
|
|
||
| detail := "" | ||
| if profile.OrganizationName != "" { | ||
| detail = fmt.Sprintf(" (org %s)", profile.OrganizationName) |
There was a problem hiding this comment.
Low: Terminal control injection via organization names
A backend can return an organization name containing newlines or ANSI/OSC control sequences, which this notice writes directly to the terminal on routine commands. This lets an attacker forge CLI output or manipulate supported terminal features such as the clipboard; strip control characters from backend-derived display values before rendering them, including the new organization and profile list outputs.
The legacy loggedInUsers roster is kept in sync with profiles for old-binary compatibility. The migration treated every roster entry without a same-named profile as a legacy session and synthesized a profile for it, so a targeted first login (login --profile x) produced a phantom email-named profile with no org and no keyring session behind it. Migration now only synthesizes a profile when no existing profile covers that account's email, and the legacy-switch reconciliation falls back to any profile for the account when no email-named one exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Demo testing surfaced one more edge: after a targeted first login ( 🤖 Generated with Claude Code |
Team review of the profiles PR surfaced a consistent complaint: `org switch` and profiles read as unrelated features. The help text was part of it (org commands talked about the "login session" while profile commands talked about "profiles"), but the deeper cause was that the organization was baked into a profile's identity, so the only way to change organization was to mutate or fork a profile. The organization is now a field of the profile, the way a namespace is a field of a kubectl context: - --org / INFISICAL_ORG override the organization for a single command without touching the profile. Selectors accept an organization name, slug, or id. - Switching organizations no longer requires re-authenticating. The session is exchanged for an organization-scoped one and cached per organization in the keyring, so repeat use costs nothing and tokens never touch the config file. - `infisical profile set-org` is the canonical way to change a profile's default organization; `infisical org switch` remains as an alias since that is the name people reach for. Both say which profile they changed. - `infisical profile current` reports the organization in effect and where it came from; `infisical org list` marks the current organization and now enumerates sub-organizations with their slugs. - Help text consistently describes the organization as profile state. `infisical init` no longer re-asks for an organization when the profile already has one, which was the sharpest symptom of the old model. It reports the organization it is using, and warns when a transient --org would leave the linked project unreachable on later runs. Also adds `infisical profile use <name> --shell`, which prints an eval-able export so a single terminal can be pinned without knowing the env var name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the feedback, all of it landed. Pushed a change that reworks the org/profile relationship, plus answers to the specific questions. On "I didn't understand that org switch and profiles were connected" (@akhil, @thiago). You were right, and it was more than wording. The organization was baked into a profile's identity, so the only way to change it was to mutate or fork a profile, while The organization is now a field of the profile, the way a namespace is a field of a kubectl context (@thiago, this is your framing, and it fit better than the terminology change):
I kept the name "profile" rather than moving to "context": two of you reached for the AWS model unprompted, and AWS/gcloud/Azure/1Password all use profile-shaped language. The kubectl idea we adopted is the conceptual separation, not the vocabulary.
@victor, on @victor, on sub-orgs: yes, and the gaps you'd have hit are closed. The pickers were already two-level, and profiles stored the sub-org, but @thiago, on session vs directory scoping: both work, but you were right that pinning a session was second-class, since it meant knowing the env var name. Added Verified end to end against a local instance: one login, then 🤖 Generated with Claude Code |
| // Pin only this shell, leaving the default and other terminals alone. | ||
| // Printed for eval so the export lands in the caller's shell: | ||
| // eval "$(infisical profile use globex --shell)" | ||
| util.PrintlnStdout(fmt.Sprintf("export %s=%s", util.INFISICAL_PROFILE_ENV_NAME, profileName)) |
There was a problem hiding this comment.
Medium: Shell command injection
Derived profile names can come from the raw email in a login response, but this line inserts the name directly into output intended for eval. A compromised server can return a name such as work$(command), causing that command to execute when the user follows the documented shell-pinning workflow. Encode the value with robust shell-safe quoting, such as POSIX single-quote escaping, before emitting the export statement.
Two related changes to how long credentials live on a machine and how they are ended. Stop renewing sessions with the refresh token. The wiring added earlier was also incorrect: the server rotates the refresh token on every refresh and returns the replacement in the response body, keeping the old one valid for only a 10 second grace window, after which reuse is treated as theft and the session is deleted. The client discarded the rotated token, so refreshing worked exactly once per login and the next attempt would have revoked the user's session. Rotating correctly needs an atomic read-rotate-write across independent CLI processes sharing one vault entry, which is not something the CLI can do safely today, and the fix that matters more is the one this makes possible: a session now lives at most JWT_AUTH_LIFETIME and expiry sends the user back through login, so forgotten sessions age out on their own. Refresh tokens are no longer written to the vault at all, since nothing uses them and storing one means a stolen vault yields a long-lived rotating credential rather than a short-lived access token. Existing stored tokens are cleared on the next credential write. Add `infisical logout`, which revokes the session on the server and removes it from the machine, with --all for every profile and --local-only to skip revocation. The server keys sessions by user, IP, and user agent, so several profiles for one account on one machine share a single session; logging out of one leaves a session another profile still uses intact and removes only the local credentials, instead of silently signing the user out of their other tenants. `profile delete` and `reset` revoke as well, so credentials that look gone locally are actually gone, and `profile list` gained a SESSION column so stored sessions are visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Session lifetime and logout, following the question about credentials staying alive unnoticed. Reverted refresh-token renewal, and it was hiding a bug. While checking whether to keep it I found the earlier wiring was incorrect. The server rotates the refresh token on every refresh, returns the replacement in the response body, and keeps the old one valid for only a 10 second grace window; reuse after that is treated as theft and the session is deleted: Our client read only So sessions are now bounded by Added
Verified end to end against a local instance: logging out of a shared-session profile leaves the other one working, logging out the last one drops the server session row to zero, and a token captured beforehand is rejected afterwards. 🤖 Generated with Claude Code |
Problem
Switching between tenants in the CLI requires a full re-login each time, and working in parallel across projects in different tenants is only possible by dumping tokens into env vars or
.envfiles. Root cause: the keyring holds exactly one session per email (the keyring key is the bare email), and the session JWT is scoped to a single organization, so logging into tenant B destroys the session for tenant A. Community ask: Infisical/infisical#2191.What this PR does
Named profiles (phase 1). A profile is one login session: account + instance + organization (+ sub-org), with its own keyring entry keyed by the profile name. Sessions for any number of tenants now coexist.
Per-invocation selection precedence:
--profileflag >INFISICAL_PROFILEenv var > directory scope > global default. Parallel terminals pin different tenants with oneexport INFISICAL_PROFILE=...each; tokens never leave the keyring.Directory scoping (phase 2). A user-level map in
~/.infisical/infisical-config.jsonbinds a directory tree to a profile (nearest ancestor wins, same upward walk as.infisical.jsondiscovery).cdinto a project and the right tenant is selected automatically. Nothing is written to the repo.New commands
infisical profile list | current [--plain] | use <name> [--scope <dir>] | unlink [path] | delete <name>infisical org listandinfisical org switch [--org-id <id>] [--save-as <profile>]: re-scopes the session via the existingselect-organizationexchange (with MFA handling) without re-authenticating;--save-asstores the result as a new profile and leaves the current one untouched.Behavior fixes that came with the rework
CallGetNewAccessTokenWithRefreshTokenTODO incredentials.go), re-scoping to the profile's org; falls back to the interactive flow on any failure, incl. MFA.--domain/INFISICAL_DOMAINnow beats the saved login domain instead of being silently overridden; a one-time warning surfaces mismatches.infisical resetdeletes the keyring entries of all stored sessions (previously it orphaned every account except the active one).infisical vault setclears all profiles, since sessions in the old backend are unreachable after the switch.infisical initpersists its org re-scope on the profile the invocation resolved to (it previously rewrote the single session as a side effect) and offers a directory binding when multiple profiles exist. Commands that resolve a profile via env var, flag, or directory scope never move the global default, so pinned terminals cannot affect other terminals.infisical user switchnow operates on profiles (same picker UX, kept for compatibility).Migration and back-compat
loggedInUserEmail/loggedInUsersbecome profiles named after the account email. That name is also the legacy keyring key, so existing keyring entries keep working with no rewrite and no re-login.user switch, the migration reconciles the divergence in favor of the legacy pointer.--silent, structured output, and for token-based invocations).INFISICAL_TOKEN, and service tokens are untouched: token-based auth still outranks the login session.Deliberately out of scope
The file-vault passphrase being stored base64-encoded in the plaintext config is a pre-existing issue and is not addressed here; it should get its own PR, ideally sequenced with this migration.
The parallel-orgs story for
org switch --save-asdepends on the server keeping the prior session token valid afterselect-organization; if the server invalidates it, the old profile simply shows as expired and can be refreshed by one login. One login per org is always sufficient to get fully parallel profiles.Testing
packages/util/profile_test.go).go build ./...,go vet(clean for changed files;run.go/pam/gateway-v2findings are pre-existing on main),go test ./packages/...(cmd package needs-vet=offdue to those pre-existing findings), e2e module builds.$HOME: legacy config migration and persistence,profile use --scope, nearest-ancestor resolution from subdirectories, env var selection, the stderr notice on a real command, unlink from a subdirectory, delete, and error paths.🤖 Generated with Claude Code