Skip to content

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

Open
scott-ray-wilson wants to merge 4 commits into
mainfrom
feat/named-profiles
Open

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

Conversation

@scott-ray-wilson

Copy link
Copy Markdown
Contributor

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 .env files. 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: --profile flag > INFISICAL_PROFILE env var > directory scope > global default. Parallel terminals pin different tenants with one export INFISICAL_PROFILE=... each; tokens never leave the keyring.

Directory scoping (phase 2). A user-level map in ~/.infisical/infisical-config.json binds a directory tree to a profile (nearest ancestor wins, same upward walk as .infisical.json discovery). cd into 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 list and infisical org switch [--org-id <id>] [--save-as <profile>]: re-scopes the session via the existing select-organization exchange (with MFA handling) without re-authenticating; --save-as stores the result as a new profile and leaves the current one untouched.

Behavior fixes that came with the rework

  • Expired sessions renew via the stored refresh token when present (wires up the previously dead CallGetNewAccessTokenWithRefreshToken TODO in credentials.go), re-scoping to the profile's org; falls back to the interactive flow on any failure, incl. MFA.
  • An explicit --domain/INFISICAL_DOMAIN now beats the saved login domain instead of being silently overridden; a one-time warning surfaces mismatches.
  • infisical reset deletes the keyring entries of all stored sessions (previously it orphaned every account except the active one).
  • infisical vault set clears all profiles, since sessions in the old backend are unreachable after the switch.
  • infisical init persists 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 switch now operates on profiles (same picker UX, kept for compatibility).

Migration and back-compat

  • Lazy, transparent migration on first read: legacy loggedInUserEmail/loggedInUsers become 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.
  • The legacy config fields stay synced with the active profile, so older CLI binaries and scripts that read them keep working. If an older binary runs user switch, the migration reconciles the divergence in favor of the legacy pointer.
  • Single-profile users see no change: no new prompts, no notices. The "Using profile X via ..." stderr notice only appears when selection came from a flag, env var, or directory scope (and is suppressed by --silent, structured output, and for token-based invocations).
  • Machine identities, 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-as depends on the server keeping the prior session token valid after select-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

  • Unit tests for migration (incl. idempotency and legacy-divergence reconciliation), resolution precedence, directory walk-up, default profile naming/dedup/suffixing, active-profile legacy sync, and profile removal (packages/util/profile_test.go).
  • go build ./..., go vet (clean for changed files; run.go/pam/gateway-v2 findings are pre-existing on main), go test ./packages/... (cmd package needs -vet=off due to those pre-existing findings), e2e module builds.
  • Smoke-tested against an isolated $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

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>
@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 resolution, organization switching, migration of legacy login state, and automatic session refresh. It also updates reset, vault, init, login, and user compatibility flows to operate on the new profile model.

  • Adds profile and organization management commands with flag, environment, directory, and default selection precedence.
  • Migrates legacy account configuration into named, profile-keyed keyring sessions.
  • Adds refresh-token renewal and organization re-scoping for expired sessions.
  • Updates reset, vault switching, initialization, and legacy user commands for profile-aware state.

Confidence Score: 1/5

This PR is not safe to merge until cross-instance domain rewriting, unreachable automatic refresh for SRP sessions, and concurrent profile-config lost updates are addressed.

Same-email profiles can be routed to the wrong instance with their bearer token, standard SRP sessions cannot use the new renewal path, and parallel profile operations can overwrite one another's durable state.

Files Needing Attention: packages/cmd/user.go, packages/cmd/login.go, packages/util/credentials.go, packages/util/profile.go, packages/util/config.go

Security Review

The legacy domain-update compatibility command rewrites every same-email profile, so a session belonging to another instance can subsequently be sent to the newly selected endpoint. How this was verified: Profile loading uses each rewritten domain as the API base while retrieving the bearer token from that profile's distinct keyring entry.

Important Files Changed

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)

  1. packages/util/profile.go, line 3411-3423 (link)

    P1 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

Comment thread packages/cmd/user.go Outdated
Comment on lines +247 to +252
// 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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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

Comment on lines +136 to +140
// 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 != "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

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

veria-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR overview

This 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>
@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

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 login --interactive then init through a pty and never checks login's exit code. On this branch, login authenticated successfully and then died locally: the derived profile name is the raw account email, and ValidateProfileName rejected characters like + that are legal in emails (the CI test account's email evidently contains one). With no session stored, init's RequireLogin auto-spawned login --silent, whose "Select your hosting option" prompt the harness has no answer for, deadlocking until the 11m test timeout. Reproduced locally under a pty. Fix: derived names (raw emails) are stored as-is and never validated; validation applies only to user-typed names (--profile, --save-as), whose charset now also allows +. Regression tests added.

greptile/veria on user update domain (valid): the sync loop now repoints only profiles whose domain matched the roster entry's previous domain, so a same-email account on a different instance never has its token redirected to the new domain.

greptile on the refresh path (valid): the password/SRP login path now stores the refresh session scraped from the jid cookie (login2/MFA responses, and v3 login now scrapes it too), so the refresh wiring works for those sessions instead of only browser logins.

Other failing checks, not related to this PR:

  • govulncheck: fails on GO-2026-6163 (panic in github.com/pion/stun/v3), a new advisory not in the workflow allowlist; go.mod is untouched here, so this fails for any PR scanned now. Needs a stun bump or allowlist entry in its own PR (the job also warns GO-2026-5942 can be dropped from the allowlist).
  • Agent End-to-End Testing: transient proxy.golang.org download errors (stream error: INTERNAL_ERROR); CLI and PAM E2E in the same run passed. Should clear on re-run.

🤖 Generated with Claude Code

Comment thread packages/util/profile.go

func syncLegacyLoginFields(configFile *models.ConfigFile, profile models.Profile) {
configFile.LoggedInUserEmail = profile.Email
configFile.LoggedInUserDomain = profile.Domain

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

One more behavior fix from demo testing: infisical login --profile <name> (or with INFISICAL_PROFILE set) no longer moves the global default profile. It previously did, which yanked every unpinned terminal onto the freshly logged-in tenant, and meant an expired-session auto-renewal (which re-execs login --profile <resolved>) could steal the default as a side effect. Targeted logins now only write their own profile, matching the source-aware rule org switch and init already follow; plain infisical login keeps last-login-wins. The CLI prints which profile remains the default after a targeted login.

🤖 Generated with Claude Code

Comment thread packages/cmd/root.go

detail := ""
if profile.OrganizationName != "" {
detail = fmt.Sprintf(" (org %s)", profile.OrganizationName)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@scott-ray-wilson

Copy link
Copy Markdown
Contributor Author

Demo testing surfaced one more edge: after a targeted first login (login --profile x), the migration synthesized a phantom email-named profile from the legacy roster mirror (no org, no keyring session behind it). Fixed in the latest commit: migration only synthesizes a profile when no existing profile covers that account's email, with regression tests for the mirror case and for legacy-switch reconciliation onto named profiles.

🤖 Generated with Claude Code

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