diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b7f8855..cb107b30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- Infisical falls back to the logged-in Infisical CLI user (`infisical login`)
+ when no token or machine identity credentials are configured. This requires
+ Infisical CLI 0.44.0 or newer, because older CLIs ignore `--domain` and could
+ hand over another instance's session. Without a terminal on stdin, the CLI
+ is stopped after 30 seconds instead of waiting for a browser login. A new
+ `?personal=true` URI option reads the user's personal overrides and makes
+ `secretspec set` write a personal override instead of the shared secret.
+ Earlier SecretSpec versions ignore `?personal=true` and write the shared
+ secret, so everyone using such an alias needs 0.22 or newer.
+
- Proton Pass refs accept `field` to read any field of an existing item, such
as `ref = { item = "test-item", field = "password" }` for a login's
password, a custom field, or `Section.field`. Refs with a `field` are
diff --git a/docs/src/content/docs/providers/infisical.mdx b/docs/src/content/docs/providers/infisical.mdx
index c3436f6e..f563ffcd 100644
--- a/docs/src/content/docs/providers/infisical.mdx
+++ b/docs/src/content/docs/providers/infisical.mdx
@@ -21,7 +21,7 @@ both Infisical Cloud and self-hosted instances.
| URI | `infisical://[host]/PROJECT_ID[?options]` |
| Access | Read and write; version-pinned references are read-only |
| Best for | Infisical Cloud or self-hosted Infisical deployments |
-| Authentication | Universal Auth machine identity or access token |
+| Authentication | Universal Auth machine identity or access token, or the Infisical CLI login (0.22+, Infisical CLI 0.44.0+) |
| Availability | SecretSpec 0.16+; requires the `infisical` build feature |
| Default storage | Key `{key}` in `{path}/{project}/{profile}` |
@@ -46,7 +46,8 @@ per folder rather than one per secret.
### Prerequisites
- An Infisical project
-- A machine identity with access to it
+- A machine identity or access token with access to it, or an Infisical CLI login (0.22+,
+ Infisical CLI 0.44.0+)
- Build with `--features infisical`
### Universal Auth machine identity
@@ -72,6 +73,54 @@ $ export INFISICAL_TOKEN=...
Service tokens are not supported: Infisical deprecated them in favour of
machine identities.
+### Infisical CLI login
+
+
+
+Without any configured credentials, the provider uses the user logged in to the
+[Infisical CLI](https://infisical.com/docs/cli/overview) 0.44.0 or newer:
+
+```bash
+$ infisical login --domain https://app.infisical.com
+
+$ secretspec run --provider "infisical://app.infisical.com/7e2f1a4c-...?env=dev" -- npm start
+```
+
+The access token comes from the first source that is configured, so CI with
+explicit credentials is unaffected:
+
+1. `token`, or `INFISICAL_TOKEN`
+2. `client_id` and `client_secret`, or `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`
+3. The Infisical CLI session
+
+For the last step SecretSpec runs:
+
+```bash
+infisical user get token --plain --silent --domain
+```
+
+SecretSpec runs it at most once at a time per instance and reuses the token until it expires;
+callers resolving in parallel share that run and its result. A failed attempt is not remembered,
+so a long-running `secretspec serve` picks up a login made after the failure.
+
+`` is the instance the URI names, e.g. `https://app.infisical.com`. Infisical CLI 0.44.0
+and newer refuse to hand over a session issued by a different instance, so log in against the same
+one (`infisical login --domain `). Older CLIs ignore `--domain` and would hand over
+another instance's session, so SecretSpec checks `infisical --version` first and refuses a CLI
+older than 0.44.0, including its pre-releases, or one whose version it cannot read.
+`INFISICAL_PROFILE` and `INFISICAL_ORG` (Infisical CLI 0.44.0+) select which CLI login and
+organization are used; they reach the CLI through the environment.
+
+When SecretSpec's standard input is a terminal, it is passed to the CLI, so an expired session
+can start the CLI's own browser login, or its prompt to paste a token, with its prompts on stderr.
+Otherwise, as in CI, agents, or pipes, the CLI gets no input and is stopped after 30 seconds with
+an error saying the session is missing or expired: run `infisical login --domain `
+interactively, or configure a token or machine identity.
+Set `SECRETSPEC_INFISICAL_PATH` when the binary is not `infisical` on `PATH`.
+
+A half-configured machine identity — only `client_id` or only `client_secret` — is still an error
+rather than a reason to fall back to the CLI login.
+
### Credentials from another provider
A machine identity's credentials can live in another store rather than in the
@@ -89,8 +138,9 @@ client_secret = "keyring"
The provider declares `client_id` and `client_secret` for Universal Auth, and
`token` for a ready-made access token. Each falls back to its corresponding
-environment variable when it is not declared. Use
-`secretspec config provider login infisical` to store declared credentials.
+environment variable when it is not declared; with none of them set, the
+provider falls back to the [Infisical CLI login](#infisical-cli-login) (0.22+).
+Use `secretspec config provider login infisical` to store declared credentials.
## Provider credentials
@@ -101,7 +151,7 @@ environment variable when it is not declared. Use
### URI format
```
-infisical://[host]/{project-id}[?env=slug&path=/prefix&tls=false]
+infisical://[host]/{project-id}[?env=slug&path=/prefix&tls=false&personal=true]
```
- `host`: the Infisical instance (falls back to `INFISICAL_DOMAIN`, then the legacy
@@ -110,6 +160,8 @@ infisical://[host]/{project-id}[?env=slug&path=/prefix&tls=false]
- `?env=`: environment slug. Without it, the SecretSpec profile names the environment
- `?path=`: folder prefix holding SecretSpec's secrets (default: `/secretspec`)
- `?tls=false`: disable TLS, for self-hosted instances served over plain HTTP
+- `?personal=true` (0.22+): read your personal overrides and write personal overrides (see
+ [Personal overrides](#personal-overrides))
Infisical's API addresses a project by UUID, not by the slug shown in its UI.
@@ -261,6 +313,38 @@ $ secretspec run --provider "infisical:///7e2f1a4c-..." -- npm start
Infisical's legacy `INFISICAL_API_URL` is honoured too, so an instance already configured for
their CLI works unchanged. `INFISICAL_DOMAIN` wins when both are set, matching the CLI.
+### Personal overrides
+
+
+
+Infisical lets a user override a shared secret with a personal value only they see. With
+`?personal=true`, reads return your personal override where one exists and the shared value
+otherwise, and `secretspec set` writes a personal override instead of changing the shared secret.
+
+```toml
+[providers]
+infisical = "infisical://app.infisical.com/7e2f1a4c-...?env=dev"
+infisical_me = "infisical://app.infisical.com/7e2f1a4c-...?env=dev&personal=true"
+```
+
+```bash
+$ secretspec set DATABASE_URL --provider infisical_me
+```
+
+- Infisical keeps personal overrides for users only, so this needs a user's token: the
+ [Infisical CLI login](#infisical-cli-login) or a user's `INFISICAL_TOKEN`. A machine identity
+ reads shared values only, and its personal writes are refused.
+- A shared secret with the same key must already exist in the folder. Create it first through an
+ alias without `?personal=true`; a personal write never creates folders.
+- Version-pinned refs read the shared secret's history.
+- Requires an Infisical version with personal overrides on its v4 API.
+
+:::caution[Pin teammates to SecretSpec 0.22+]
+SecretSpec before 0.22 ignores `personal=true`: it reads the shared value and `secretspec set`
+overwrites the team's shared secret instead of writing a personal override. Make sure everyone
+using a manifest with a `personal=true` alias runs SecretSpec 0.22 or newer.
+:::
+
### Approval policies
A project under an approval policy turns a write into a change request: Infisical stores nothing
diff --git a/docs/src/content/docs/reference/providers.mdx b/docs/src/content/docs/reference/providers.mdx
index d39a2d26..5f6d6993 100644
--- a/docs/src/content/docs/reference/providers.mdx
+++ b/docs/src/content/docs/reference/providers.mdx
@@ -715,7 +715,7 @@ selects the null label
Available since SecretSpec 0.16.
-**URI**: `infisical://[HOST]/PROJECT_ID[?env=SLUG][&path=/PREFIX][&tls=false]` - Stores secrets in Infisical
+**URI**: `infisical://[HOST]/PROJECT_ID[?env=SLUG][&path=/PREFIX][&tls=false][&personal=true]` - Stores secrets in Infisical. `personal=true` (0.22+) reads and writes the user's personal overrides; SecretSpec before 0.22 ignores it and writes the shared secret, so pin teammates to 0.22+.
```text
infisical://app.infisical.com/7e2f1a4c-... # Infisical Cloud (US)
@@ -728,9 +728,9 @@ The project is Infisical's project **UUID** (Project Settings → Project ID); i
accept the project slug. Without a host, the provider reads `INFISICAL_DOMAIN`, then Infisical's
legacy `INFISICAL_API_URL`, then defaults to Infisical Cloud.
-**Features**: Read/write, cloud sync, profiles, machine-identity (Universal Auth) or token auth, secret references, version-pinned refs
-**Prerequisites**: An Infisical project, a machine identity with access to it, build with `--features infisical`
-**Authentication**: `INFISICAL_CLIENT_ID` + `INFISICAL_CLIENT_SECRET` (Universal Auth), or a ready-made `INFISICAL_TOKEN`. Service tokens are not supported; Infisical deprecated them in favour of machine identities.
+**Features**: Read/write, cloud sync, profiles, machine-identity (Universal Auth) or token auth, Infisical CLI login (0.22+), personal overrides (0.22+), secret references, version-pinned refs
+**Prerequisites**: An Infisical project, a machine identity or access token with access to it, or an Infisical CLI login (0.22+, Infisical CLI 0.44.0+), build with `--features infisical`
+**Authentication**: `INFISICAL_CLIENT_ID` + `INFISICAL_CLIENT_SECRET` (Universal Auth), or a ready-made `INFISICAL_TOKEN`. With neither configured, the logged-in Infisical CLI user's session is used (0.22+), via `infisical user get token --domain `; SecretSpec refuses Infisical CLIs older than 0.44.0, which ignore `--domain`. Without a terminal on stdin, the CLI is stopped after 30 seconds. Service tokens are not supported; Infisical deprecated them in favour of machine identities.
**Storage**: Secret `{key}` in folder `/secretspec/{project}/{profile}`, in the environment named by the profile (or by `?env=`). Keys are stored verbatim.
By default the SecretSpec profile names the Infisical environment, so a `production` profile reads
diff --git a/secretspec/src/provider/infisical.rs b/secretspec/src/provider/infisical.rs
index 6c313f32..514079b8 100644
--- a/secretspec/src/provider/infisical.rs
+++ b/secretspec/src/provider/infisical.rs
@@ -6,18 +6,36 @@
//!
//! # Authentication
//!
-//! A machine identity's `client_id` and `client_secret` are exchanged for a
-//! short-lived access token via Universal Auth, cached for the life of the
-//! process. A `token` minted elsewhere is used directly instead, which is what
-//! CI usually has to hand. Credentials come from the provider alias or from
-//! `INFISICAL_CLIENT_ID` / `INFISICAL_CLIENT_SECRET` / `INFISICAL_TOKEN`.
+//! The access token comes from the first source that is configured:
+//!
+//! 1. A `token` minted elsewhere, used directly; this is what CI usually has to
+//! hand. Provider credential `token`, else `INFISICAL_TOKEN`.
+//! 2. A machine identity's `client_id` and `client_secret`, exchanged for a
+//! short-lived access token via Universal Auth. Provider credentials, else
+//! `INFISICAL_CLIENT_ID` / `INFISICAL_CLIENT_SECRET`. Naming only one of the
+//! pair is an error rather than a reason to fall through.
+//! 3. The logged-in Infisical CLI user (0.22+), read with
+//! `infisical user get token --plain --silent --domain `. The
+//! binary is `infisical` on `PATH`, or `SECRETSPEC_INFISICAL_PATH`;
+//! `INFISICAL_PROFILE` / `INFISICAL_ORG` reach it through the environment.
+//! Infisical CLI 0.44.0 or newer is required, checked with
+//! `infisical --version`: older CLIs ignore `--domain` and would hand over
+//! a session issued by another instance. With stdin on a terminal the CLI
+//! inherits it and may take as long as a login needs; otherwise it gets no
+//! stdin and is stopped after 30 seconds.
+//!
+//! A token or machine identity is resolved once per provider instance and kept
+//! for its life. A CLI session is shared by every instance in the process that
+//! would run the same CLI for the same instance, profile and organization: at
+//! most one run is in flight at a time, and its token is reused until it is
+//! within a minute of its expiry.
//!
//! Service tokens are not supported: Infisical deprecated them in favour of
//! machine identities.
//!
//! # URI Format
//!
-//! `infisical://[host]/{project-id}[?env=slug&path=/prefix&tls=false]`
+//! `infisical://[host]/{project-id}[?env=slug&path=/prefix&tls=false&personal=true]`
//!
//! The project is Infisical's own project UUID; its v4 API does not accept a
//! project slug. Query parameters:
@@ -32,6 +50,14 @@
//! `/secretspec`).
//! - `tls` -- enable TLS: `true` (default) or `false`, for self-hosted
//! instances served over plain HTTP.
+//! - `personal` (0.22+) -- `true` reads the user's personal overrides, which
+//! win over the shared value, and makes `set` write a personal override
+//! instead of the shared secret. Default `false`. Infisical keeps personal
+//! overrides for users only, so this needs a user's token (the Infisical CLI
+//! login or a user's `INFISICAL_TOKEN`), and an override can only be written
+//! for a key whose shared secret already exists in the folder. A machine
+//! identity reads shared values and cannot write. A `ref` pinning a
+//! `version` reads the shared secret's history.
//!
//! When no host is given, falls back to `INFISICAL_DOMAIN`, then to Infisical's
//! legacy `INFISICAL_API_URL`, then to `app.infisical.com`.
@@ -82,7 +108,8 @@ use crate::{Result, SecretSpecError};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
-use std::sync::{Mutex, OnceLock};
+use std::sync::{Arc, LazyLock, Mutex, OnceLock, PoisonError};
+use std::time::{Duration, SystemTime};
/// Default folder prefix holding SecretSpec's secrets.
const DEFAULT_PATH: &str = "/secretspec";
@@ -96,12 +123,30 @@ const TOKEN: &str = "token";
const INFISICAL_CLIENT_ID_ENV: &str = "INFISICAL_CLIENT_ID";
const INFISICAL_CLIENT_SECRET_ENV: &str = "INFISICAL_CLIENT_SECRET";
const INFISICAL_TOKEN_ENV: &str = "INFISICAL_TOKEN";
+/// Overrides the Infisical CLI binary used for the login-session fallback.
+const INFISICAL_CLI_PATH_ENV: &str = "SECRETSPEC_INFISICAL_PATH";
+const DEFAULT_CLI_BINARY: &str = "infisical";
/// The environment variables naming the instance, in the Infisical CLI's own
/// precedence order: `INFISICAL_DOMAIN` first, then the legacy
/// `INFISICAL_API_URL` it superseded but still honours (`util.GetEnvDomain`).
/// Reading only the current name would silently send an existing EU or
/// self-hosted setup, configured with the legacy one, to US Cloud.
const INFISICAL_DOMAIN_ENVS: [&str; 2] = ["INFISICAL_DOMAIN", "INFISICAL_API_URL"];
+/// The oldest Infisical CLI whose `--domain` refuses a session issued by
+/// another instance. Older ones replace the requested instance with the
+/// logged-in one, so their token would go to whichever instance SecretSpec
+/// asked for. They also predate `INFISICAL_PROFILE` and `INFISICAL_ORG`.
+const MIN_CLI_VERSION: (u64, u64, u64) = (0, 44, 0);
+/// The environment variables through which the Infisical CLI selects a login
+/// profile and organization. Each selects a different session, so each is
+/// part of a cached session's identity.
+const INFISICAL_CLI_SESSION_ENVS: [&str; 2] = ["INFISICAL_PROFILE", "INFISICAL_ORG"];
+/// How long a CLI run without a terminal may take. With no stdin to answer a
+/// login prompt, a CLI still running by then is waiting for a login nobody can
+/// complete.
+const CLI_DETACHED_TIMEOUT: Duration = Duration::from_secs(30);
+/// How close to its expiry a CLI token stops being reused.
+const CLI_TOKEN_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
/// Configuration for the Infisical provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -114,6 +159,10 @@ pub struct InfisicalConfig {
pub environment: Option,
/// Folder prefix holding SecretSpec's secrets.
pub path: String,
+ /// Read the user's personal overrides, and write personal overrides
+ /// instead of shared secrets.
+ #[serde(default)]
+ pub personal_overrides: bool,
}
impl Default for InfisicalConfig {
@@ -123,6 +172,7 @@ impl Default for InfisicalConfig {
project_id: String::new(),
environment: None,
path: DEFAULT_PATH.to_string(),
+ personal_overrides: false,
}
}
}
@@ -251,11 +301,26 @@ impl TryFrom<&ProviderUrl> for InfisicalConfig {
None => DEFAULT_PATH.to_string(),
};
+ // Strict like `tls`: a typo must not silently write the shared value.
+ let personal_overrides = url
+ .query_pairs()
+ .find(|(k, _)| k == "personal")
+ .map(|(_, v)| match v.as_ref() {
+ "true" | "1" => Ok(true),
+ "false" | "0" => Ok(false),
+ other => Err(SecretSpecError::ProviderOperationFailed(format!(
+ "Unknown personal value '{other}'. Expected 'true' or 'false'."
+ ))),
+ })
+ .transpose()?
+ .unwrap_or(false);
+
Ok(Self {
endpoint,
project_id,
environment,
path,
+ personal_overrides,
})
}
}
@@ -276,15 +341,92 @@ enum SecretRead {
NotFound,
}
+/// Where a provider instance's access token comes from, decided on first use.
+enum Auth {
+ /// A configured token, or one exchanged for the machine identity's
+ /// credentials, kept for the instance's life.
+ Fixed(Arc),
+ /// The Infisical CLI user's session, shared process-wide.
+ Cli(Arc),
+}
+
+/// What makes two Infisical CLI runs interchangeable: the same binary, asked
+/// for the same instance, under the same profile and organization selection
+/// ([`INFISICAL_CLI_SESSION_ENVS`], in order).
+type CliSessionKey = (String, String, [Option; 2]);
+
+/// The latest CLI run for one [`CliSessionKey`]: still in flight while its
+/// cell is empty, then its outcome. A failure is kept as the message every
+/// caller that waited on that run receives.
+type CliFlight = tokio::sync::OnceCell>;
+
+/// One Infisical CLI session, shared by every provider instance in the process
+/// with the same [`CliSessionKey`].
+#[derive(Default)]
+struct CliSession {
+ /// Set once the binary's version is accepted, for the life of the process.
+ version_accepted: tokio::sync::OnceCell<()>,
+ /// The latest run; see [`CliFlight`].
+ flight: Mutex>,
+}
+
+impl CliSession {
+ /// The session for `key`, created on first use.
+ fn shared(key: CliSessionKey) -> Arc {
+ static SESSIONS: LazyLock>>> =
+ LazyLock::new(Default::default);
+ let mut sessions = SESSIONS.lock().unwrap_or_else(PoisonError::into_inner);
+ Arc::clone(sessions.entry(key).or_default())
+ }
+
+ /// The run a caller takes its token from: the one in flight, whatever its
+ /// outcome, or a finished one whose token is still fresh. Otherwise a new
+ /// run replaces it, so neither a failure nor an expiring token outlives
+ /// the callers that waited on it.
+ fn flight(&self) -> Arc {
+ let mut flight = self.flight.lock().unwrap_or_else(PoisonError::into_inner);
+ let joinable = match flight.get() {
+ None => true,
+ Some(Ok(cli)) => cli.reusable_at(SystemTime::now()),
+ Some(Err(_)) => false,
+ };
+ if !joinable {
+ *flight = Arc::default();
+ }
+ Arc::clone(&flight)
+ }
+}
+
+/// A token the Infisical CLI printed.
+struct CliToken {
+ token: Arc,
+ /// The JWT's `exp`, when it carries a readable one.
+ expires_at: Option,
+}
+
+impl CliToken {
+ /// Whether a later caller may reuse this token: its `exp` is readable and
+ /// more than [`CLI_TOKEN_EXPIRY_MARGIN`] away.
+ fn reusable_at(&self, now: SystemTime) -> bool {
+ self.expires_at.is_some_and(|expires_at| {
+ expires_at
+ .duration_since(now)
+ .is_ok_and(|left| left > CLI_TOKEN_EXPIRY_MARGIN)
+ })
+ }
+}
+
/// Infisical provider.
pub struct InfisicalProvider {
retry_policy: super::RetryPolicy,
config: InfisicalConfig,
/// Credentials supplied by the provider alias.
credentials: ProviderCredentials,
- /// The Universal Auth access token, fetched once per process. Infisical's
+ /// How this instance authenticates, decided once. A configured token, or
+ /// the Universal Auth token, is kept for the instance's life: Infisical's
/// tokens are short-lived (7200s by default), which outlives any single
- /// SecretSpec invocation.
+ /// SecretSpec invocation. A CLI session is consulted on every call instead
+ /// (see [`cli_token`](Self::cli_token)), since it renews on expiry.
///
/// Async, because the exchange itself must be serialized rather than merely
/// its result: a batch read fetches distinct addresses concurrently, and a
@@ -292,7 +434,7 @@ pub struct InfisicalProvider {
/// then discard all but one. Infisical supports client secrets with a
/// one-use limit, for which the surplus exchanges are not just waste but a
/// hard failure.
- token: tokio::sync::OnceCell,
+ auth: tokio::sync::OnceCell,
/// One HTTP client for every request, so a run of secrets reuses the
/// connection rather than building a pool per call.
http: OnceLock,
@@ -305,6 +447,14 @@ pub struct InfisicalProvider {
/// [`storage_identity`](Provider::storage_identity), or the same store would
/// take one identity per profile.
profile: Mutex