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 4 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 profile persistence and organization/profile notices in command output. Two issues remain open after one was addressed. Legacy compatibility state can under specific cross-instance profile conditions cause an older CLI to send a token to the wrong instance, while unsanitized organization names can inject terminal control sequences into CLI output. Both require attacker influence over a backend and particular runtime conditions, but the credential-routing case should be resolved before release. Open issues (2)
Fixed/addressed: 1 · PR risk: 4/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 |
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