feat(run): support fetching secrets from a non-default organization#300
feat(run): support fetching secrets from a non-default organization#300fdarian wants to merge 3 commits into
Conversation
The CLI holds a single org-scoped JWT and uses it against whatever workspace .infisical.json names, so `infisical run` against a project in a different organization than the one selected at login is rejected by the backend, even when the account has access to both organizations. Resolve a target organization id from a new --organization-id flag, the INFISICAL_ORGANIZATION_ID env var, or a new optional organizationId field that `infisical init` now writes to .infisical.json. When it differs from the current token's organizationId claim, mint a token scoped to that org via the existing select-organization endpoint (the same call `infisical init` already uses) and use it for that invocation only, without touching the stored credential. When no target org is set, behavior is unchanged and no extra request is made.
When the resolved token still gets a 403 "project does not belong to your selected organization", enumerate the organizations the account belongs to, re-scope into each and retry the fetch, and on the first success persist the discovered organizationId into .infisical.json so later runs route directly. This lets project configs that predate the organizationId field work without a re-init. Discovery only runs on that 403, so correct-org and single-org fetches make no extra requests.
|
| Filename | Overview |
|---|---|
| packages/util/secrets.go | Core change: adds org-scoping logic (resolveOrgScopedToken, fetchSecretsWithOrgDiscovery, persistDiscoveredOrganizationId). The 403-based org-discovery trigger is too broad — any 403 (permission denial, wrong workspace, etc.) fires the enumeration loop. |
| packages/cmd/run.go | Adds --organization-id flag and threads it through fetchSecrets / fetchAndFormatSecretsForShell / executeCommandWithWatchMode; plumbing is consistent and correct. |
| packages/cmd/init.go | Now passes selectedOrgID into writeWorkspaceFile and delegates serialization to the new WriteWorkspaceConfigToPath helper; clean refactor. |
| packages/models/cli.go | Adds OrganizationId (omitempty) to WorkspaceConfigFile and GetAllSecretsParameters; additive, backward-compatible. |
| packages/util/config.go | Extracts WriteWorkspaceConfigToPath helper from init.go; straightforward refactor, no issues. |
| packages/util/secrets_org_scope_test.go | New unit tests for getTokenOrganizationId, resolveOrgScopedToken, isOrganizationScopeError, and WriteWorkspaceConfigToPath; coverage is good for the non-network branches. |
Reviews (1): Last reviewed commit: "feat(run): auto-discover a project's org..." | Re-trigger Greptile
| func isOrganizationScopeError(err error) bool { | ||
| var apiErr *api.APIError | ||
| return errors.As(err, &apiErr) && apiErr.StatusCode == 403 | ||
| } |
There was a problem hiding this comment.
isOrganizationScopeError fires for every 403, not only the org-scope case. The Infisical backend returns distinct messages for authorization errors (e.g., "You don't have access to this project") versus org-scoping errors ("This project does not belong to your selected organization"). When a user genuinely lacks read permission on a workspace or secrets path, the current code will silently enumerate all of their organizations and retry — adding O(N orgs) extra API calls — before returning the original 403 unchanged. Consider checking the error message/name in addition to the status code, or at minimum document the intentional choice here.
| func isOrganizationScopeError(err error) bool { | |
| var apiErr *api.APIError | |
| return errors.As(err, &apiErr) && apiErr.StatusCode == 403 | |
| } | |
| func isOrganizationScopeError(err error) bool { | |
| var apiErr *api.APIError | |
| if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 { | |
| return false | |
| } | |
| // Only trigger org discovery for the specific org-scoping rejection. | |
| // Generic permission/auth 403s should not silently enumerate all orgs. | |
| return strings.Contains(apiErr.ErrorMessage, "does not belong to your selected organization") | |
| } |
There was a problem hiding this comment.
Good catch. Narrowed isOrganizationScopeError to only match the org-scope rejection (403 plus the "does not belong to your selected organization" message), so a generic permission 403 no longer enumerates every organization. Added a test for the permission-403 case as well.
Fixed in 3990325.
isOrganizationScopeError matched every 403, so a generic permission rejection would enumerate all of the account's organizations before failing. Match the specific "does not belong to your selected organization" backend message instead.
Description
Closes #299
If you belong to two organizations, log in and select org A, then
infisical runin a project that lives in org B fails, even though your account has access to both. The stored session is a single org-scoped JWT, andrunsends it against whateverworkspaceId.infisical.jsonnames with no awareness of which org that project is in, so the backend rejects the org-A token against an org-B workspace. Today the only workaround is toinfisical loginagain and pick the other org.The re-scoping mechanism to fix this already exists:
infisical initlets a logged-in user pick a different organization mid-session and callsPOST /v3/auth/select-organizationwith the current token to mint a token scoped to the chosen org. This PR reuses that same call in the secrets-fetch path.Changes:
.infisical.jsongains an optionalorganizationId, whichinfisical initnow records for the selected project (additive,omitempty, existing config files keep working). When present it lets the fetch route to the right org directly.infisical rungains an--organization-idflag.runandexportresolves a target org id in priority order:--organization-idflag, thenINFISICAL_ORGANIZATION_ID, then.infisical.json. If a target org is set and differs from the current token'sorganizationIdclaim, it mints a token for that org viaselect-organizationand uses it for that invocation only. The stored keyring credential is never overwritten.organizationIdback into.infisical.jsonso later runs route directly. This is what makes an existing project just work without a re-init.When no target org is resolved and the fetch succeeds (single-org users, or a config already scoped to the right org), the code path and the number of requests are unchanged. Discovery only runs on the org-scope 403.
Type
Tests
Added unit tests in
packages/util/secrets_org_scope_test.gofor the non-network branches: the org-id claim decoder, the source-priority resolution, the403org-scope error predicate, and the.infisical.jsonwriter round-trip. Gates:Manual repro, for an account that belongs to two orgs A and B with a project in each:
Demo
2026-07-06.15-30-23.mp4
The demo uses
@infisical, a locally built CLI carrying this change:Walkthrough with two organizations, A and B, each holding a project my account can access:
More demos: writing
organizationIdinto .infisical.jsonSelf-healing an existing config (organizationId written on first run)
2026-07-06.15-33-52.mp4
infisical initrecords organizationId in a fresh config2026-07-06.15-36-12.mp4