From 56bae9a056667c26b8c315a521766ff5f4273311 Mon Sep 17 00:00:00 2001 From: Otto Hermann Date: Mon, 20 Jul 2026 20:02:34 +0000 Subject: [PATCH] feat(providers): general ?layout=flat setting to resolve at the store root Maps SecretSpec's {project}/{profile}/{key} convention onto the store root: the secret is addressed by key alone, dropping the secretspec/{project}/{profile} scaffolding but keeping any provider-native container (a Vault mount) or user prefix (an AWS ?prefix=, an Infisical ?path=). The default nested layout is unchanged. Honored by every hierarchical provider -- Infisical, Vault, AWS Secrets Manager, Google Cloud Secret Manager and Azure Key Vault. Providers with no hierarchy (dotenv, env, bws) are already flat and ignore it. Layout is a shared, per-provider-instance setting: parsed once through a strict ProviderUrl::layout() (nested/flat, unknown value errors), then honored by each provider's convention_address in its own dialect, so it works correctly in fallback chains. Flat addresses by key alone, so project/profile aren't validated; where the backend constrains names the key must fit, and Azure Key Vault -- which uses the key verbatim -- refuses an underscore the nested layout would have Base32-encoded away. Dropping the profile gives up profile separation unless the provider pins its environment another way (an Infisical ?env=); flat plus such a pin collapses every profile onto one key deliberately. Resolves #181. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ docs/src/content/docs/providers/akv.md | 21 ++ docs/src/content/docs/providers/awssm.md | 21 ++ docs/src/content/docs/providers/gcsm.md | 23 +- docs/src/content/docs/providers/infisical.md | 50 +++- docs/src/content/docs/providers/vault.md | 21 ++ docs/src/content/docs/reference/providers.md | 61 ++++- secretspec/src/provider/akv.rs | 132 +++++++++-- secretspec/src/provider/awssm.rs | 163 ++++++++++--- secretspec/src/provider/gcsm.rs | 84 ++++++- secretspec/src/provider/infisical.rs | 235 ++++++++++++++++++- secretspec/src/provider/mod.rs | 51 ++++ secretspec/src/provider/vault.rs | 94 +++++++- 13 files changed, 891 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653f9a4d..7694df75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Flat layout (`?layout=flat`), a general provider setting that resolves and + writes convention secrets by key alone at the store root, dropping the + `secretspec/{project}/{profile}` scaffolding — the natural shape for a + single-project store, such as one migrated from another secret manager. + Supported by every hierarchical provider (Infisical, Vault, AWS Secrets + Manager, Google Cloud Secret Manager and Azure Key Vault); providers with no + hierarchy (`dotenv`, `env`, `bws`) are already flat and ignore it. Any + provider-native container (a Vault mount) or user-supplied prefix (an AWS + `?prefix=`, an Infisical `?path=`) still applies. The default nested layout is + unchanged. Dropping the profile gives up profile separation, so a provider + that pins its environment another way (an Infisical `?env=`) combined with + `?layout=flat` collapses every profile onto one key deliberately; and because + the key is used as the backend name directly, it must be a legal name there + (Azure Key Vault, which uses it verbatim, refuses a key it cannot store). - Vault / OpenBao JWT/OIDC authentication (`?auth=jwt`) logs in through a configured Vault role using `VAULT_JWT`, or requests a short-lived OIDC token automatically in GitHub Actions and Forgejo Actions jobs with `id-token: diff --git a/docs/src/content/docs/providers/akv.md b/docs/src/content/docs/providers/akv.md index ba175e8f..2ee52a9e 100644 --- a/docs/src/content/docs/providers/akv.md +++ b/docs/src/content/docs/providers/akv.md @@ -67,6 +67,7 @@ akv://VAULT_NAME[?auth=env|cli|managed_identity|workload_identity][&suffix=DNS_S - `managed_identity` — the VM / App Service / AKS system-assigned managed identity - `workload_identity` — AKS workload identity federation (`AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_FEDERATED_TOKEN_FILE`, injected automatically by AKS) - `suffix`: an explicit Key Vault DNS suffix for a bare `VAULT_NAME`, e.g. `akv://myvault?suffix=vault.azure.cn` for a sovereign cloud, instead of relying on a dotted `VAULT_NAME` +- `?layout=flat` (0.17+): use the key as the Azure secret name verbatim, with no Base32-encoded `{project}/{profile}` scaffolding — see [Layout](#layout-017) ### URI examples @@ -101,6 +102,26 @@ though Key Vault's identifiers do not preserve all of those distinctions. The encoded components contain no hyphens, so the `--` component separators cannot be confused with component data. +### Layout (0.17+) + +Added in SecretSpec 0.17; `?layout=flat` is not available in SecretSpec 0.16 or earlier. + +`?layout=` is a [general provider setting](/reference/providers/#layout-flat-017), spelled the same +way across every hierarchical backend. The default **nested** layout Base32-encodes the components +into `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}`, as above. + +The **flat** layout (`?layout=flat`) drops the `{project}/{profile}` scaffolding and uses the +`{key}` as the Azure secret name **verbatim** — the shape a store migrated from another manager +already has. Because there is no Base32 rewrite to fall back on, the key must itself be a legal +Azure Key Vault secret name (`^[0-9a-zA-Z-]+$`): an underscore, which the nested layout would have +encoded away, is refused rather than pointing at a name Azure cannot store. The project and profile +name no part of the secret name under flat, so they are not required. + +```toml title="secretspec.toml" +[providers] +akv = "akv://myvault?layout=flat" +``` + ## Use existing secrets A secret's diff --git a/docs/src/content/docs/providers/awssm.md b/docs/src/content/docs/providers/awssm.md index ae721437..b0ed67ac 100644 --- a/docs/src/content/docs/providers/awssm.md +++ b/docs/src/content/docs/providers/awssm.md @@ -95,6 +95,7 @@ awssm://[AWS_PROFILE@]REGION[?prefix=PREFIX][&kms_key_id=KEY][&tag.NAME=VALUE... - `PREFIX`: Optional root prefix prepended to all secret names. Useful when IAM policies scope access by prefix (e.g., only allow `myteam/*`). - `kms_key_id`: Optional KMS key (id, ARN, or `alias/...`) used to encrypt secrets that secretspec creates. - `tag.NAME=VALUE`: Optional tags applied to secrets that secretspec creates. Repeat for multiple tags. +- `?layout=flat` (0.17+): name secrets `[prefix/]{key}` with no `secretspec/{project}/{profile}` scaffolding — see [Layout](#layout-017) `kms_key_id` and `tag.NAME=VALUE` are applied **only when secretspec creates a secret** (`CreateSecret`); updating a value (`PutSecretValue`) accepts neither, @@ -139,6 +140,26 @@ For example, `DATABASE_URL` in project `myapp` and profile `production` is stored as `secretspec/myapp/production/DATABASE_URL`. With `?prefix=myteam`, it becomes `myteam/secretspec/myapp/production/DATABASE_URL`. +### Layout (0.17+) + +Added in SecretSpec 0.17; `?layout=flat` is not available in SecretSpec 0.16 or earlier. + +`?layout=` is a [general provider setting](/reference/providers/#layout-flat-017), spelled the same +way across every hierarchical backend. The default **nested** layout names each secret +`[prefix/]secretspec/{project}/{profile}/{key}`, as above. + +The **flat** layout (`?layout=flat`) drops the `secretspec/{project}/{profile}` scaffolding, so a +convention secret is named `[prefix/]{key}` — `DATABASE_URL`, or `myteam/DATABASE_URL` with +`?prefix=myteam`. Any `?prefix=` still applies, so IAM policies that scope access by prefix keep +working. This is the natural shape for a single-project store, e.g. one migrated from another +manager. Because the project and profile name no part of the secret name under flat, they are not +required. + +```toml title="secretspec.toml" +[providers] +awssm = "awssm://us-east-1?layout=flat" +``` + ## Use existing secrets A secret's [`ref`](/reference/configuration/#secret-references) field names an diff --git a/docs/src/content/docs/providers/gcsm.md b/docs/src/content/docs/providers/gcsm.md index e5c8b164..50e63e73 100644 --- a/docs/src/content/docs/providers/gcsm.md +++ b/docs/src/content/docs/providers/gcsm.md @@ -54,10 +54,11 @@ service account automatically. ### URI format ``` -gcsm://PROJECT_ID +gcsm://PROJECT_ID[?layout=flat] ``` - `PROJECT_ID`: Your GCP project ID +- `?layout=flat` (0.17+): use the key alone as the secret id, with no `secretspec-{project}-{profile}-` prefix — see [Layout](#layout-017) ### URI examples @@ -81,6 +82,26 @@ Secrets are stored as `secretspec-{project}-{profile}-{key}`. For example, project `myapp`, profile `production`, and key `DATABASE_URL` map to `secretspec-myapp-production-DATABASE_URL`. +### Layout (0.17+) + +Added in SecretSpec 0.17; `?layout=flat` is not available in SecretSpec 0.16 or earlier. + +`?layout=` is a [general provider setting](/reference/providers/#layout-flat-017), spelled the same +way across every hierarchical backend. The default **nested** layout builds the secret id +`secretspec-{project}-{profile}-{key}`, as above. + +The **flat** layout (`?layout=flat`) drops that prefix, so a convention secret's id is the `{key}` +itself — `DATABASE_URL` maps straight to a `DATABASE_URL` secret. This is the natural shape for a +single-project store, e.g. one migrated from another manager. The project and profile name no part +of the id under flat, so they are not required; the key must still be a legal GCP secret id +(letters, digits, hyphens and underscores), since there is no `secretspec-` prefix rewriting the +rest of the name. + +```toml title="secretspec.toml" +[providers] +gcsm = "gcsm://my-gcp-project?layout=flat" +``` + ## Use existing secrets A secret's [`ref`](/reference/configuration/#secret-references) field names an diff --git a/docs/src/content/docs/providers/infisical.md b/docs/src/content/docs/providers/infisical.md index beefbe80..bfcf2355 100644 --- a/docs/src/content/docs/providers/infisical.md +++ b/docs/src/content/docs/providers/infisical.md @@ -94,14 +94,16 @@ 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&layout=flat&tls=false] ``` - `host`: the Infisical instance (falls back to `INFISICAL_DOMAIN`, then the legacy `INFISICAL_API_URL`, then `app.infisical.com`) - `{project-id}`: the project's **UUID**, from Project Settings → Project ID - `?env=`: environment slug. Without it, the SecretSpec profile names the environment -- `?path=`: folder prefix holding SecretSpec's secrets (default: `/secretspec`) +- `?path=`: folder prefix holding SecretSpec's secrets. Defaults to `/secretspec` under the + nested layout and to the environment root (`/`) under the flat one +- `?layout=` (0.17+): `nested` (default) or `flat` — see [Layout](#layout-017) - `?tls=false`: disable TLS, for self-hosted instances served over plain HTTP Infisical's API addresses a project by UUID, not by the slug shown in its UI. @@ -173,6 +175,50 @@ renamed. Folders are created as needed when writing a secret. +### Layout (0.17+) + +Added in SecretSpec 0.17; the flat layout is not available in SecretSpec 0.16. + +The default **nested** layout stores secrets under `{path}/{project}/{profile}`, so many projects +and profiles can share one Infisical store without colliding. + +The **flat** layout (`?layout=flat`) drops the `{project}/{profile}` folders, so a secret sits +directly at the folder prefix — the environment root by default, or `{path}` when one is given: + +``` +project "myapp", profile "prod", key "DATABASE_URL", layout flat + -> environment prod + folder / + key DATABASE_URL +``` + +```toml title="secretspec.toml" +[providers] +# Read secrets straight from each environment's root +infisical = "infisical://app.infisical.com/7e2f1a4c-...?layout=flat" + +# ...or from a shared prefix, still with no project/profile folders +infisical_team = "infisical://app.infisical.com/7e2f1a4c-...?layout=flat&path=/team" +``` + +This is the natural shape for a **single-project store** — one migrated from another secret +manager, say — where SecretSpec's namespace folders would only be in the way. Because the flat +layout no longer puts the project or profile in a folder name, those names are unconstrained under +it. + +The profile still names the environment, so distinct profiles stay apart: + +``` +dev profile, key DATABASE_URL -> environment dev, folder /, key DATABASE_URL +prod profile, key DATABASE_URL -> environment prod, folder /, key DATABASE_URL +``` + +:::caution +Pinning `?env=` **and** `?layout=flat` together collapses every profile onto one environment root, +so they share a key. That combination gives up profile separation deliberately, and is only safe +when a single profile is ever resolved against the store. +::: + ## Use existing secrets A secret can name one Infisical secret by its own coordinates, instead of SecretSpec's layout: diff --git a/docs/src/content/docs/providers/vault.md b/docs/src/content/docs/providers/vault.md index 29ff7a7e..bf82f0d6 100644 --- a/docs/src/content/docs/providers/vault.md +++ b/docs/src/content/docs/providers/vault.md @@ -100,6 +100,7 @@ openbao://[namespace@]host[:port][/mount][?key=value&...] - `?role=` (0.17+): Vault role for JWT auth (or `VAULT_JWT_ROLE`) - `?audience=` (0.17+): OIDC token audience for JWT auth (or `VAULT_JWT_AUDIENCE`) - `?kv=1`: Use KV v1 engine (default: v2) +- `?layout=flat` (0.17+): address secrets by key alone at the mount root, with no `secretspec/{project}/{profile}` scaffolding — see [Layout](#layout-017) - `?tls=false`: Disable TLS (for development servers) ### URI examples @@ -131,6 +132,26 @@ configured mount, with its value in a field named `value`. For KV v2, `DATABASE_URL` for project `myapp` and profile `production` is read from `GET /v1/secret/data/secretspec/myapp/production/DATABASE_URL`. +### Layout (0.17+) + +Added in SecretSpec 0.17; `?layout=flat` is not available in SecretSpec 0.16 or earlier. + +`?layout=` is a [general provider setting](/reference/providers/#layout-flat-017), spelled the same +way across every hierarchical backend. The default **nested** layout stores each secret at +`secretspec/{project}/{profile}/{key}` under the mount, as above. + +The **flat** layout (`?layout=flat`) drops that scaffolding, so a convention secret is the `{key}` +itself at the mount root — for KV v2, `DATABASE_URL` is read from +`GET /v1/secret/data/DATABASE_URL` under the configured mount, still with its value in a `value` +field. This is the natural shape for a single-project store, e.g. one migrated from another manager +where the secrets already live at the mount root. Because the project and profile name no path +segment under flat, they are not required. + +```toml title="secretspec.toml" +[providers] +vault = "vault://vault.example.com:8200/secret?layout=flat" +``` + ## Use existing secrets A secret's diff --git a/docs/src/content/docs/reference/providers.md b/docs/src/content/docs/reference/providers.md index bb9527ed..31ad7e5e 100644 --- a/docs/src/content/docs/reference/providers.md +++ b/docs/src/content/docs/reference/providers.md @@ -124,29 +124,31 @@ protonpass://Work/{project}/{profile}/{key} # Custom vault and title temp ## Google Cloud Secret Manager Provider -**URI**: `gcsm://PROJECT_ID` - Stores secrets in Google Cloud Secret Manager +**URI**: `gcsm://PROJECT_ID[?layout=flat]` - Stores secrets in Google Cloud Secret Manager ```bash -gcsm://my-gcp-project # GCP project ID +gcsm://my-gcp-project # GCP project ID +gcsm://my-gcp-project?layout=flat # (0.17+) Secret id is the key alone ``` **Features**: Read/write, cloud sync, profiles, service account support **Prerequisites**: `gcloud` CLI, authenticated, Secret Manager API enabled, build with `--features gcsm` -**Storage**: Secret name `secretspec-{project}-{profile}-{key}` +**Storage**: Secret name `secretspec-{project}-{profile}-{key}`, or the `{key}` alone under [`?layout=flat`](#layout-flat-017) ## AWS Secrets Manager Provider -**URI**: `awssm://[profile@]REGION` - Stores secrets in AWS Secrets Manager +**URI**: `awssm://[profile@]REGION[?prefix=PREFIX][&layout=flat]` - Stores secrets in AWS Secrets Manager ```bash awssm://us-east-1 # Specific AWS region awssm://production@us-east-1 # Specific AWS profile and region +awssm://us-east-1?layout=flat # (0.17+) Secret name is [prefix/]key awssm:// # SDK default region and credentials ``` **Features**: Read/write, cloud sync, profiles, IAM/SSO authentication **Prerequisites**: AWS credentials configured, build with `--features awssm` -**Storage**: Secret name `secretspec/{project}/{profile}/{key}` +**Storage**: Secret name `[prefix/]secretspec/{project}/{profile}/{key}`, or `[prefix/]{key}` under [`?layout=flat`](#layout-flat-017) ## Vault / OpenBao Provider @@ -170,7 +172,7 @@ vault://127.0.0.1:8200/secret?tls=false # Disable TLS (dev mode) **Features**: Read/write, KV v1 and v2, namespaces, OpenBao compatible; token and AppRole authentication; JWT/OIDC authentication (0.17+) **Prerequisites**: Vault/OpenBao server, authentication credentials, build with `--features vault` -**Storage**: KV path `secretspec/{project}/{profile}/{key}` with a `value` field +**Storage**: KV path `secretspec/{project}/{profile}/{key}` with a `value` field, or the `{key}` alone at the mount root under [`?layout=flat`](#layout-flat-017) ## Bitwarden Secrets Manager Provider @@ -204,16 +206,17 @@ akv://myvault?suffix=vault.azure.cn # Sovereign cloud (explicit suffix, bar **Features**: Read/write, cloud sync, profiles, service principal/managed identity/workload identity auth **Prerequisites**: An Azure Key Vault instance, authenticated via one of the methods above, build with `--features akv` -**Storage**: Secret name `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}` (lowercase, unpadded Base32 preserves case and punctuation distinctions within Azure's case-insensitive secret-name namespace) +**Storage**: Secret name `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}` (lowercase, unpadded Base32 preserves case and punctuation distinctions within Azure's case-insensitive secret-name namespace), or the `{key}` used verbatim as the secret name under [`?layout=flat`](#layout-flat-017) ## Infisical Provider (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][&layout=flat][&tls=false]` - Stores secrets in Infisical ```bash infisical://app.infisical.com/7e2f1a4c-... # Infisical Cloud (US) infisical://eu.infisical.com/7e2f1a4c-... # Infisical Cloud (EU) infisical://vault.example.com/7e2f1a4c-...?env=prod # Read every profile from one environment +infisical://app.infisical.com/7e2f1a4c-...?layout=flat # (0.17+) Read from the environment root, no folders infisical://localhost:8080/7e2f1a4c-...?tls=false # Self-hosted over plain HTTP ``` @@ -230,9 +233,51 @@ By default the SecretSpec profile names the Infisical environment, so a `product the `production` environment. Projects whose environments do not correspond to profiles pin one with `?env=`; the profile still names the folder, so profiles never share a secret. +`?layout=flat` (0.17+) drops the `{project}/{profile}` folders so secrets resolve at the environment +root (or at `?path=` when given) — the natural shape for a single-project store, e.g. one migrated +from another manager. The profile still names the environment, so profiles stay apart; combining +`?layout=flat` with a pinned `?env=` collapses them onto one root and is only safe when a single +profile is resolved against the store. + Values are read with Infisical's secret references expanded, matching its own CLI, so a value of `postgres://${DB_USER}@host` arrives resolved. +## Layout: `flat` (0.17+) + +:::note[Version compatibility] +Added in SecretSpec 0.17. `?layout=flat` is not available in SecretSpec 0.16 or earlier. +::: + +`?layout=` is a **general provider setting**, spelled the same way across every provider whose +store has a hierarchy: Infisical, Vault, AWS Secrets Manager, Google Cloud Secret Manager and Azure +Key Vault. It controls how SecretSpec's `{project}/{profile}/{key}` naming convention maps onto the +store: + +- **`nested`** (the default) keeps the `secretspec/{project}/{profile}` scaffolding, so many + projects and profiles can share one store without colliding. This is the shape SecretSpec creates. +- **`flat`** drops that scaffolding, so a convention secret is addressed by its **key alone**, at + the store root — under any provider-native container (a Vault mount) or user-supplied prefix (an + AWS `?prefix=`, an Infisical `?path=`), but with no project/profile segments. This is the natural + shape of a **single-project store**, e.g. one migrated from another secret manager where the + secrets already live at the root. + +Because the flat layout no longer puts the project or profile in a name, those names are +unconstrained under it. Two caveats follow from addressing by key alone: + +- **Profile separation.** Dropping the profile means distinct profiles no longer separate + themselves by name. A provider that pins its environment another way (an Infisical `?env=`) can + still keep profiles apart, but flat plus such a pin collapses every profile onto one key and gives + up profile separation deliberately — only safe when a single profile is ever resolved against the + store. +- **The key must fit the store.** With no `secretspec-` rewriting to fall back on, the key itself + must be a legal name in the backend. Azure Key Vault is the strictest: under flat the key is used + as the secret name verbatim, so it must match `^[0-9a-zA-Z-]+$` (an underscore, which the nested + layout would have Base32-encoded away, is refused). + +Providers with no hierarchy (`dotenv`, `env`, `bws`) are already flat and ignore the setting. + +An unreadable value (anything but `nested` or `flat`) is refused rather than guessed. + ## Provider Selection ### Command Line diff --git a/secretspec/src/provider/akv.rs b/secretspec/src/provider/akv.rs index 392061bf..0e6d9cf2 100644 --- a/secretspec/src/provider/akv.rs +++ b/secretspec/src/provider/akv.rs @@ -62,7 +62,7 @@ //! secretspec check --provider akv://myvault?auth=managed_identity //! ``` -use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; +use super::{Address, Layout, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; use crate::{Result, SecretSpecError}; use azure_core::credentials::{Secret, TokenCredential}; use azure_core::http::StatusCode; @@ -136,6 +136,9 @@ pub struct AkvConfig { /// (contains a dot) or the default public suffix was used, so `uri()` /// only emits `suffix` when it was actually given. pub suffix: Option, + /// How convention secrets map onto secret names (SecretSpec 0.17+). + #[serde(default)] + pub layout: Layout, } /// The public-cloud Key Vault DNS suffix. Sovereign clouds (China, US Gov, @@ -190,11 +193,16 @@ impl TryFrom<&ProviderUrl> for AkvConfig { ))); } + // `layout` is the shared, cross-provider setting, parsed the same way + // everywhere; an unreadable value is refused rather than guessed. + let layout = url.layout()?; + Ok(Self { vault_host, vault_url, auth, suffix, + layout, }) } } @@ -267,11 +275,50 @@ impl AkvProvider { /// Formats and validates the secret name for Azure Key Vault. /// - /// Converts the SecretSpec path format to an Azure-compatible name: + /// Under the nested [`Layout`] the SecretSpec path becomes the + /// Azure-compatible name /// `secretspec--{base32(project)}--{base32(profile)}--{base32(key)}`. /// Lowercase, unpadded Base32 avoids both Key Vault's case-insensitive /// identifier comparisons and ambiguity with the `--` component delimiter. - fn format_secret_name(project: &str, profile: &str, key: &str) -> Result { + /// + /// Under the flat layout the `{project}/{profile}` scaffolding is dropped + /// and the `key` is used as the Azure secret name **verbatim** -- the shape + /// a store migrated from elsewhere already has. There is no Base32 rewrite + /// to fall back on, so the key must itself be Azure-legal (letters, digits + /// and hyphens only): an underscore, which the nested layout would have + /// encoded away, is refused rather than silently pointing at a name Azure + /// cannot store. + fn format_secret_name( + layout: Layout, + project: &str, + profile: &str, + key: &str, + ) -> Result { + if layout == Layout::Flat { + if key.is_empty() { + return Err(SecretSpecError::ProviderOperationFailed( + "key cannot be empty".to_string(), + )); + } + for c in key.chars() { + if !c.is_ascii_alphanumeric() && c != '-' { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "key '{key}' cannot be an Azure Key Vault secret name under the flat \ + layout: only letters, digits and hyphens are allowed, and the key is \ + used verbatim. Rename it, or address the secret by its own coordinates \ + with ref = {{ item = \"NAME\" }}." + ))); + } + } + if key.len() > 127 { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Secret name too long: {} characters (max 127)", + key.len() + ))); + } + return Ok(key.to_string()); + } + Self::validate_name_component("project", project)?; Self::validate_name_component("profile", profile)?; Self::validate_name_component("key", key)?; @@ -512,7 +559,8 @@ impl AkvProvider { impl Provider for AkvProvider { /// Convention names use lowercase Base32 components so they remain - /// injective despite Azure Key Vault's restricted, case-insensitive names. + /// injective despite Azure Key Vault's restricted, case-insensitive names; + /// under `?layout=flat` the `key` is the Azure secret name verbatim. fn convention_address( &self, project: &str, @@ -520,7 +568,7 @@ impl Provider for AkvProvider { key: &str, ) -> Result { Ok(crate::config::NativeAddress { - item: Self::format_secret_name(project, profile, key)?, + item: Self::format_secret_name(self.config.layout, project, profile, key)?, ..Default::default() }) } @@ -542,6 +590,9 @@ impl Provider for AkvProvider { if let Some(suffix) = &self.config.suffix { params.push(format!("suffix={suffix}")); } + if self.config.layout == Layout::Flat { + params.push("layout=flat".to_string()); + } if !params.is_empty() { uri.push('?'); uri.push_str(¶ms.join("&")); @@ -597,27 +648,34 @@ mod tests { #[test] fn test_format_secret_name() { - let name = AkvProvider::format_secret_name("myapp", "prod", "DB_URL").unwrap(); + let name = + AkvProvider::format_secret_name(Layout::Nested, "myapp", "prod", "DB_URL").unwrap(); assert_eq!(name, "secretspec--nv4wc4dq--obzg6za--irbf6vksjq"); } #[test] fn test_format_secret_name_rejects_invalid_chars() { - assert!(AkvProvider::format_secret_name("my/app", "prod", "DB_URL").is_err()); - assert!(AkvProvider::format_secret_name("myapp", "prod", "DB URL").is_err()); + assert!( + AkvProvider::format_secret_name(Layout::Nested, "my/app", "prod", "DB_URL").is_err() + ); + assert!( + AkvProvider::format_secret_name(Layout::Nested, "myapp", "prod", "DB URL").is_err() + ); } #[test] fn test_format_secret_name_too_long() { let long_key = "A".repeat(127); - let result = AkvProvider::format_secret_name("myapp", "prod", &long_key); + let result = AkvProvider::format_secret_name(Layout::Nested, "myapp", "prod", &long_key); assert!(result.is_err()); } #[test] fn test_format_secret_name_preserves_case_distinctions() { - let upper = AkvProvider::format_secret_name("app", "prod", "API_KEY").unwrap(); - let lower = AkvProvider::format_secret_name("app", "prod", "api_key").unwrap(); + let upper = + AkvProvider::format_secret_name(Layout::Nested, "app", "prod", "API_KEY").unwrap(); + let lower = + AkvProvider::format_secret_name(Layout::Nested, "app", "prod", "api_key").unwrap(); assert_ne!(upper, lower); assert_eq!(upper, upper.to_ascii_lowercase()); assert_eq!(lower, lower.to_ascii_lowercase()); @@ -625,18 +683,62 @@ mod tests { #[test] fn test_format_secret_name_prevents_boundary_delimiter_collision() { - let trailing = AkvProvider::format_secret_name("a", "b-", "C").unwrap(); - let leading = AkvProvider::format_secret_name("a", "b", "_C").unwrap(); + let trailing = AkvProvider::format_secret_name(Layout::Nested, "a", "b-", "C").unwrap(); + let leading = AkvProvider::format_secret_name(Layout::Nested, "a", "b", "_C").unwrap(); assert_ne!(trailing, leading); } #[test] fn test_format_secret_name_encodes_internal_delimiters() { - let left = AkvProvider::format_secret_name("a", "b__c", "d").unwrap(); - let right = AkvProvider::format_secret_name("a", "b", "c__d").unwrap(); + let left = AkvProvider::format_secret_name(Layout::Nested, "a", "b__c", "d").unwrap(); + let right = AkvProvider::format_secret_name(Layout::Nested, "a", "b", "c__d").unwrap(); assert_ne!(left, right); } + /// Flat uses the key as the Azure secret name verbatim, dropping the + /// Base32-encoded `{project}/{profile}` scaffolding -- the shape a store + /// migrated from elsewhere already has. + #[test] + fn flat_layout_uses_the_key_verbatim() { + let c = config("akv://myvault?layout=flat"); + assert_eq!(c.layout, Layout::Flat); + // project and profile name no part of the id, so they are not required. + let name = AkvProvider::format_secret_name(Layout::Flat, "", "", "DATABASE-URL").unwrap(); + assert_eq!(name, "DATABASE-URL"); + } + + /// Because the flat name is used verbatim, a key Azure cannot store -- an + /// underscore, which the nested layout would have Base32-encoded away -- is + /// refused rather than pointing at a name the API will reject. + #[test] + fn flat_layout_rejects_a_key_azure_cannot_store() { + let err = AkvProvider::format_secret_name(Layout::Flat, "myapp", "prod", "DATABASE_URL") + .unwrap_err(); + assert!(err.to_string().contains("used verbatim"), "{err}"); + } + + /// `?layout=flat` round-trips through `uri()`; nested stays unspelled. + #[test] + fn flat_layout_round_trips_through_uri() { + let p = AkvProvider::new(config("akv://myvault?layout=flat")); + assert_eq!(p.uri(), "akv://myvault?layout=flat"); + assert_eq!(config(&p.uri()).layout, Layout::Flat); + assert_eq!( + AkvProvider::new(config("akv://myvault")).uri(), + "akv://myvault" + ); + } + + /// An unreadable layout is refused rather than guessed. + #[test] + fn unreadable_layout_is_rejected() { + let err = AkvConfig::try_from(&ProviderUrl::new( + Url::parse("akv://myvault?layout=banana").unwrap(), + )) + .unwrap_err(); + assert!(err.to_string().contains("layout value 'banana'"), "{err}"); + } + #[test] fn test_classify_env_credentials_all_set() { let result = AkvProvider::classify_env_credentials( diff --git a/secretspec/src/provider/awssm.rs b/secretspec/src/provider/awssm.rs index d3af5d6f..11ff13f6 100644 --- a/secretspec/src/provider/awssm.rs +++ b/secretspec/src/provider/awssm.rs @@ -47,7 +47,7 @@ //! secretspec check --provider awssm://production@us-east-1 //! ``` -use super::{Address, Provider, ProviderUrl}; +use super::{Address, Layout, Provider, ProviderUrl}; use crate::{Result, SecretSpecError}; use aws_sdk_secretsmanager::Client; use aws_sdk_secretsmanager::types::Tag; @@ -76,6 +76,9 @@ pub struct AwssmConfig { /// iteration (and thus `uri()`) is deterministic for the audit log. #[serde(default)] pub tags: BTreeMap, + /// How convention secrets map onto secret names (SecretSpec 0.17+). + #[serde(default)] + pub layout: Layout, } impl TryFrom<&ProviderUrl> for AwssmConfig { @@ -130,12 +133,17 @@ impl TryFrom<&ProviderUrl> for AwssmConfig { ))); } + // `layout` is the shared, cross-provider setting, parsed the same way + // everywhere; an unreadable value is refused rather than guessed. + let layout = url.layout()?; + Ok(Self { region, aws_profile, prefix, kms_key_id, tags, + layout, }) } } @@ -165,32 +173,45 @@ impl AwssmProvider { /// Formats the secret name for AWS Secrets Manager. /// - /// Uses the pattern: `[prefix/]secretspec/{project}/{profile}/{key}` + /// Under the nested [`Layout`] the pattern is + /// `[prefix/]secretspec/{project}/{profile}/{key}`. Under the flat layout + /// the `secretspec/{project}/{profile}` scaffolding is dropped, so the name + /// is `[prefix/]{key}` -- the shape a store migrated from elsewhere already + /// has, with any IAM-scoping prefix still applied. fn format_secret_name( + layout: Layout, prefix: Option<&str>, project: &str, profile: &str, key: &str, ) -> Result { - if project.is_empty() { - return Err(SecretSpecError::ProviderOperationFailed( - "project cannot be empty".to_string(), - )); - } - if profile.is_empty() { - return Err(SecretSpecError::ProviderOperationFailed( - "profile cannot be empty".to_string(), - )); - } if key.is_empty() { return Err(SecretSpecError::ProviderOperationFailed( "key cannot be empty".to_string(), )); } - - let secret_name = match prefix { - Some(p) => format!("{}/secretspec/{}/{}/{}", p, project, profile, key), - None => format!("secretspec/{}/{}/{}", project, profile, key), + let secret_name = if layout == Layout::Flat { + // Flat addresses by key alone, so the project and profile that name + // no path segment are not required and are not validated. + match prefix { + Some(p) => format!("{}/{}", p, key), + None => key.to_string(), + } + } else { + if project.is_empty() { + return Err(SecretSpecError::ProviderOperationFailed( + "project cannot be empty".to_string(), + )); + } + if profile.is_empty() { + return Err(SecretSpecError::ProviderOperationFailed( + "profile cannot be empty".to_string(), + )); + } + match prefix { + Some(p) => format!("{}/secretspec/{}/{}/{}", p, project, profile, key), + None => format!("secretspec/{}/{}/{}", project, profile, key), + } }; // AWS secret names can be up to 512 characters @@ -399,7 +420,8 @@ impl AwssmProvider { } impl Provider for AwssmProvider { - /// Convention secrets are named `[{prefix}/]secretspec/{project}/{profile}/{key}`. + /// Convention secrets are named `[{prefix}/]secretspec/{project}/{profile}/{key}`, + /// or `[{prefix}/]{key}` under `?layout=flat`. fn convention_address( &self, project: &str, @@ -407,7 +429,13 @@ impl Provider for AwssmProvider { key: &str, ) -> Result { Ok(crate::config::NativeAddress { - item: Self::format_secret_name(self.config.prefix.as_deref(), project, profile, key)?, + item: Self::format_secret_name( + self.config.layout, + self.config.prefix.as_deref(), + project, + profile, + key, + )?, ..Default::default() }) } @@ -426,6 +454,9 @@ impl Provider for AwssmProvider { // Reconstruct every query parameter. `tags` is a BTreeMap, so it // iterates in sorted key order and `uri()` is deterministic. let mut params: Vec = Vec::new(); + if self.config.layout == Layout::Flat { + params.push("layout=flat".to_string()); + } if let Some(prefix) = &self.config.prefix { params.push(format!("prefix={}", ProviderUrl::encode_query(prefix))); } @@ -505,36 +536,57 @@ mod tests { #[test] fn test_format_secret_name() { - let name = AwssmProvider::format_secret_name(None, "myapp", "prod", "DB_URL").unwrap(); + let name = + AwssmProvider::format_secret_name(Layout::Nested, None, "myapp", "prod", "DB_URL") + .unwrap(); assert_eq!(name, "secretspec/myapp/prod/DB_URL"); } #[test] fn test_format_secret_name_with_prefix() { - let name = - AwssmProvider::format_secret_name(Some("myteam"), "myapp", "prod", "DB_URL").unwrap(); + let name = AwssmProvider::format_secret_name( + Layout::Nested, + Some("myteam"), + "myapp", + "prod", + "DB_URL", + ) + .unwrap(); assert_eq!(name, "myteam/secretspec/myapp/prod/DB_URL"); } #[test] fn test_format_secret_name_with_nested_prefix() { - let name = - AwssmProvider::format_secret_name(Some("org/team"), "myapp", "prod", "DB_URL").unwrap(); + let name = AwssmProvider::format_secret_name( + Layout::Nested, + Some("org/team"), + "myapp", + "prod", + "DB_URL", + ) + .unwrap(); assert_eq!(name, "org/team/secretspec/myapp/prod/DB_URL"); } #[test] fn test_format_secret_name_too_long() { let long_key = "A".repeat(500); - let result = AwssmProvider::format_secret_name(None, "myapp", "prod", &long_key); + let result = + AwssmProvider::format_secret_name(Layout::Nested, None, "myapp", "prod", &long_key); assert!(result.is_err()); } #[test] fn test_format_secret_name_empty_inputs() { - assert!(AwssmProvider::format_secret_name(None, "", "prod", "KEY").is_err()); - assert!(AwssmProvider::format_secret_name(None, "proj", "", "KEY").is_err()); - assert!(AwssmProvider::format_secret_name(None, "proj", "prod", "").is_err()); + assert!( + AwssmProvider::format_secret_name(Layout::Nested, None, "", "prod", "KEY").is_err() + ); + assert!( + AwssmProvider::format_secret_name(Layout::Nested, None, "proj", "", "KEY").is_err() + ); + assert!( + AwssmProvider::format_secret_name(Layout::Nested, None, "proj", "prod", "").is_err() + ); } #[test] @@ -709,4 +761,61 @@ mod tests { assert!(msg.contains("is not JSON"), "{msg}"); assert!(msg.contains("db") && msg.contains("password"), "{msg}"); } + + /// Flat drops the `secretspec/{project}/{profile}` scaffolding: a convention + /// secret is named by its key alone. + #[test] + fn flat_layout_names_by_key_alone() { + let p = AwssmProvider::new(config("awssm://us-east-1?layout=flat")); + assert_eq!(p.config.layout, Layout::Flat); + assert_eq!( + p.convention_address("myapp", "prod", "DB_URL") + .unwrap() + .item, + "DB_URL" + ); + // Flat needs no project/profile, so neither is validated. + assert_eq!( + p.convention_address("", "", "DB_URL").unwrap().item, + "DB_URL" + ); + } + + /// A user prefix still scopes the name under flat, ahead of the bare key -- + /// IAM policies keyed on the prefix keep working. + #[test] + fn flat_layout_keeps_the_user_prefix() { + let p = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam&layout=flat")); + assert_eq!( + p.convention_address("myapp", "prod", "DB_URL") + .unwrap() + .item, + "myteam/DB_URL" + ); + } + + /// `?layout=flat` round-trips through `uri()`; the default nested layout + /// stays unspelled. + #[test] + fn flat_layout_round_trips_through_uri() { + let p = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam&layout=flat")); + assert_eq!(config(&p.uri()).layout, Layout::Flat); + assert!(p.uri().contains("layout=flat"), "{}", p.uri()); + assert!( + !AwssmProvider::new(config("awssm://us-east-1")) + .uri() + .contains("layout") + ); + } + + /// An unreadable layout is refused rather than guessed. + #[test] + fn unreadable_layout_is_rejected() { + use url::Url; + let err = AwssmConfig::try_from(&ProviderUrl::new( + Url::parse("awssm://us-east-1?layout=banana").unwrap(), + )) + .unwrap_err(); + assert!(err.to_string().contains("layout value 'banana'"), "{err}"); + } } diff --git a/secretspec/src/provider/gcsm.rs b/secretspec/src/provider/gcsm.rs index 25b04819..2b28769b 100644 --- a/secretspec/src/provider/gcsm.rs +++ b/secretspec/src/provider/gcsm.rs @@ -30,7 +30,7 @@ //! secretspec check --provider gcsm://my-gcp-project //! ``` -use super::{Address, Provider, ProviderUrl}; +use super::{Address, Layout, Provider, ProviderUrl}; use crate::{Result, SecretSpecError}; use google_cloud_secretmanager_v1::client::SecretManagerService; use google_cloud_secretmanager_v1::model::{Replication, Secret, SecretPayload, replication}; @@ -44,6 +44,9 @@ use serde::{Deserialize, Serialize}; pub struct GcsmConfig { /// The GCP project ID (e.g., "my-gcp-project") pub project_id: String, + /// How convention secrets map onto secret IDs (SecretSpec 0.17+). + #[serde(default)] + pub layout: Layout, } /// Validates a GCP project ID format. @@ -136,7 +139,11 @@ impl TryFrom<&ProviderUrl> for GcsmConfig { ))); } - Ok(Self { project_id }) + // `layout` is the shared, cross-provider setting, parsed the same way + // everywhere; an unreadable value is refused rather than guessed. + let layout = url.layout()?; + + Ok(Self { project_id, layout }) } } @@ -189,13 +196,22 @@ impl GcsmProvider { /// Formats and validates the secret name for GCP Secret Manager. /// - /// Converts the SecretSpec path format to GCP-compatible name: - /// `secretspec-{project}-{profile}-{key}` + /// Under the nested [`Layout`] the SecretSpec path becomes the + /// GCP-compatible name `secretspec-{project}-{profile}-{key}`. Under the + /// flat layout the scaffolding is dropped and the secret ID is the `key` + /// itself -- the shape a store migrated from elsewhere already has. /// /// GCP Secret Manager secret IDs must: /// - Be 1-255 characters long /// - Contain only alphanumeric characters, hyphens, and underscores fn format_secret_name(&self, project: &str, profile: &str, key: &str) -> Result { + // Flat addresses by key alone, so the project and profile that name no + // part of the secret ID are not required and are not validated. + if self.config.layout == Layout::Flat { + Self::validate_name_component("key", key)?; + return Ok(key.to_string()); + } + // Validate each component Self::validate_name_component("project", project)?; Self::validate_name_component("profile", profile)?; @@ -342,7 +358,8 @@ impl GcsmProvider { impl Provider for GcsmProvider { /// Convention secrets are ids of the form /// `secretspec-{project}-{profile}-{key}` (GCP secret ids cannot contain - /// slashes), read at their latest version. + /// slashes), or the `key` alone under `?layout=flat`, read at their latest + /// version. fn convention_address( &self, project: &str, @@ -360,7 +377,11 @@ impl Provider for GcsmProvider { } fn uri(&self) -> String { - format!("gcsm://{}", self.config.project_id) + let mut uri = format!("gcsm://{}", self.config.project_id); + if self.config.layout == Layout::Flat { + uri.push_str("?layout=flat"); + } + uri } /// An optional `version` pins the secret version to read. @@ -397,6 +418,57 @@ mod reference_tests { use super::*; use url::Url; + fn config(s: &str) -> GcsmConfig { + GcsmConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap() + } + + /// The flat layout drops the `secretspec-{project}-{profile}-` prefix, so a + /// convention secret's id is the key itself -- the shape a store migrated + /// from elsewhere already has. + #[test] + fn flat_layout_uses_the_key_as_the_secret_id() { + let c = config("gcsm://my-project?layout=flat"); + assert_eq!(c.layout, Layout::Flat); + let p = GcsmProvider::new(c); + assert_eq!( + p.convention_address("myapp", "prod", "db-url") + .unwrap() + .item, + "db-url" + ); + } + + /// The key still has to be a legal GCP secret id under flat -- there is no + /// `secretspec-` prefix rewriting the rest of the name. + #[test] + fn flat_layout_still_validates_the_key() { + let p = GcsmProvider::new(config("gcsm://my-project?layout=flat")); + let err = p.convention_address("myapp", "prod", "db/url").unwrap_err(); + assert!(err.to_string().contains("invalid character"), "{err}"); + } + + /// `?layout=flat` round-trips through `uri()`; nested stays unspelled. + #[test] + fn flat_layout_round_trips_through_uri() { + let p = GcsmProvider::new(config("gcsm://my-project?layout=flat")); + assert_eq!(p.uri(), "gcsm://my-project?layout=flat"); + assert_eq!(config(&p.uri()).layout, Layout::Flat); + assert_eq!( + GcsmProvider::new(config("gcsm://my-project")).uri(), + "gcsm://my-project" + ); + } + + /// An unreadable layout is refused rather than guessed. + #[test] + fn unreadable_layout_is_rejected() { + let err = GcsmConfig::try_from(&ProviderUrl::new( + Url::parse("gcsm://my-project?layout=banana").unwrap(), + )) + .unwrap_err(); + assert!(err.to_string().contains("layout value 'banana'"), "{err}"); + } + /// A path that is not a `secrets/...` resource is rejected. #[test] fn path_is_rejected_with_ref_hint() { diff --git a/secretspec/src/provider/infisical.rs b/secretspec/src/provider/infisical.rs index c7771a1c..7683ed6e 100644 --- a/secretspec/src/provider/infisical.rs +++ b/secretspec/src/provider/infisical.rs @@ -17,7 +17,7 @@ //! //! # URI Format //! -//! `infisical://[host]/{project-id}[?env=slug&path=/prefix&tls=false]` +//! `infisical://[host]/{project-id}[?env=slug&path=/prefix&layout=flat&tls=false]` //! //! The project is Infisical's own project UUID; its v4 API does not accept a //! project slug. Query parameters: @@ -26,8 +26,10 @@ //! environment, so a `production` profile reads Infisical's `production` //! environment. Set it to read every profile from one environment, e.g. an //! instance whose environments do not correspond to profiles. -//! - `path` -- folder prefix holding SecretSpec's secrets (default: -//! `/secretspec`). +//! - `path` -- folder prefix holding SecretSpec's secrets. Defaults to +//! `/secretspec` under the nested layout and to the environment root (`/`) +//! under the flat one. +//! - `layout` -- `nested` (default) or `flat`; see [Secret Naming](#secret-naming). //! - `tls` -- enable TLS: `true` (default) or `false`, for self-hosted //! instances served over plain HTTP. //! @@ -43,8 +45,9 @@ //! //! # Secret Naming //! -//! A secret lives at the folder `{path}/{project}/{profile}` in the -//! environment named by the profile, under the key itself: +//! Under the default **nested** layout a secret lives at the folder +//! `{path}/{project}/{profile}` in the environment named by the profile, under +//! the key itself: //! //! ```text //! project "myapp", profile "prod", key "DATABASE_URL" @@ -57,6 +60,25 @@ //! `?env=` moves every profile into one environment without two of them //! landing on the same secret. //! +//! The **flat** layout (`?layout=flat`) drops the `{project}/{profile}` +//! scaffolding, so a secret sits directly at the folder prefix -- the +//! environment root by default, or `{path}` when one is given: +//! +//! ```text +//! project "myapp", profile "prod", key "DATABASE_URL", layout flat +//! -> environment prod +//! secretPath / +//! secretKey DATABASE_URL +//! ``` +//! +//! This is the shape a single-project store already has -- a store migrated +//! from another manager, say -- where SecretSpec's namespace folders would only +//! be in the way. The profile still names the environment, so distinct profiles +//! stay apart. But pinning `?env=` under a flat layout collapses every profile +//! onto one environment root, so they share a key: that combination gives up +//! profile separation deliberately, and is only safe when a single profile is +//! ever resolved against the store. +//! //! Keys are stored verbatim: Infisical imposes no charset of its own, so no //! rewriting is needed and distinct keys cannot collide. Folder names are //! narrower, so a project or profile Infisical cannot spell is refused rather @@ -66,7 +88,7 @@ //! Infisical's own precedence: a secret defined directly in the folder wins //! over an imported one, and a later import wins over an earlier one. -use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; +use super::{Address, Layout, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; use reqwest::StatusCode; @@ -75,8 +97,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::OnceLock; -/// Default folder prefix holding SecretSpec's secrets. +/// Default folder prefix holding SecretSpec's secrets under the nested layout. const DEFAULT_PATH: &str = "/secretspec"; +/// The environment root, which is where the flat layout defaults its prefix. +const ROOT_PATH: &str = "/"; /// Infisical Cloud's US host, used when neither the URI nor the environment /// names one. const DEFAULT_HOST: &str = "app.infisical.com"; @@ -94,6 +118,27 @@ const INFISICAL_TOKEN_ENV: &str = "INFISICAL_TOKEN"; /// self-hosted setup, configured with the legacy one, to US Cloud. const INFISICAL_DOMAIN_ENVS: [&str; 2] = ["INFISICAL_DOMAIN", "INFISICAL_API_URL"]; +/// The folder prefix used when the URI names none: the `/secretspec` namespace +/// container under the nested [`Layout`], and the environment root under the +/// flat one. The layout is the shared, cross-provider setting; this is how +/// Infisical maps it onto its own default folder. +fn default_path(layout: Layout) -> &'static str { + match layout { + Layout::Nested => DEFAULT_PATH, + Layout::Flat => ROOT_PATH, + } +} + +/// Joins an absolute folder prefix and a key into a secret path, without +/// doubling the separator when the prefix is the root. +fn join_prefix(path: &str, key: &str) -> String { + if path == ROOT_PATH { + format!("/{key}") + } else { + format!("{path}/{key}") + } +} + /// Configuration for the Infisical provider. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InfisicalConfig { @@ -105,6 +150,9 @@ pub struct InfisicalConfig { pub environment: Option, /// Folder prefix holding SecretSpec's secrets. pub path: String, + /// How a convention address maps onto Infisical's folders. + #[serde(default)] + pub layout: Layout, } impl Default for InfisicalConfig { @@ -114,6 +162,7 @@ impl Default for InfisicalConfig { project_id: String::new(), environment: None, path: DEFAULT_PATH.to_string(), + layout: Layout::Nested, } } } @@ -230,6 +279,12 @@ impl TryFrom<&ProviderUrl> for InfisicalConfig { let environment = url.query_value("env").filter(|s| !s.is_empty()); + // `layout` is the shared, cross-provider setting, parsed the same way + // everywhere; an unreadable value is refused rather than guessed. + let layout = url.layout()?; + + // The default prefix follows the layout: `/secretspec` namespaces the + // nested layout, while the flat one sits at the environment root. let path = match url.query_value("path").filter(|s| !s.is_empty()) { Some(p) => { let trimmed = p.trim_end_matches('/'); @@ -239,7 +294,7 @@ impl TryFrom<&ProviderUrl> for InfisicalConfig { format!("/{trimmed}") } } - None => DEFAULT_PATH.to_string(), + None => default_path(layout).to_string(), }; Ok(Self { @@ -247,6 +302,7 @@ impl TryFrom<&ProviderUrl> for InfisicalConfig { project_id, environment, path, + layout, }) } } @@ -896,6 +952,17 @@ impl Provider for InfisicalProvider { move the secret to another folder." ))); } + + // The flat layout drops the `{project}/{profile}` folders, so the + // secret sits at the prefix itself and neither name reaches a folder -- + // the folder-spelling rule below has nothing to constrain. + if self.config.layout == Layout::Flat { + return Ok(NativeAddress { + item: join_prefix(&self.config.path, key), + ..Default::default() + }); + } + // The project and profile each name a folder, and Infisical spells // folder names in a narrower alphabet than keys. Rewriting a name to // fit would let two projects share a folder, so an unspellable one is @@ -944,7 +1011,12 @@ impl Provider for InfisicalProvider { if let Some(env) = &self.config.environment { query.push(format!("env={}", ProviderUrl::encode_query(env))); } - if self.config.path != DEFAULT_PATH { + if self.config.layout == Layout::Flat { + query.push("layout=flat".to_string()); + } + // A prefix equal to the layout's own default reads back the same + // without being spelled, so only a divergent one is rendered. + if self.config.path != default_path(self.config.layout) { query.push(format!( "path={}", ProviderUrl::encode_query(&self.config.path) @@ -1148,6 +1220,146 @@ mod tests { ); } + /// The flat layout defaults its prefix to the environment root, so a + /// convention secret sits there with no `{project}/{profile}` folders. + #[test] + fn flat_layout_addresses_the_root() { + let c = config(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=flat" + )); + assert_eq!(c.layout, Layout::Flat); + assert_eq!(c.path, "/"); + + let p = InfisicalProvider::new(c); + assert_eq!( + p.convention_address("myapp", "prod", "API_KEY") + .unwrap() + .item, + "/API_KEY" + ); + let loc = p + .locate(Address::convention("myapp", "prod", "API_KEY")) + .unwrap(); + assert_eq!(loc.secret_path, "/"); + assert_eq!(loc.environment, "prod"); + assert_eq!(loc.key, "API_KEY"); + } + + /// An explicit `?path=` under the flat layout is honored as the prefix, + /// still with no `{project}/{profile}` scaffolding. + #[test] + fn flat_layout_honors_an_explicit_prefix() { + let p = provider(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=flat&path=/team" + )); + assert_eq!( + p.convention_address("myapp", "prod", "API_KEY") + .unwrap() + .item, + "/team/API_KEY" + ); + let loc = p + .locate(Address::convention("myapp", "prod", "API_KEY")) + .unwrap(); + assert_eq!(loc.secret_path, "/team"); + assert_eq!(loc.key, "API_KEY"); + } + + /// The flat layout puts neither project nor profile in a folder, so a name + /// Infisical could not spell as a folder is no longer constrained. + #[test] + fn flat_layout_does_not_constrain_project_or_profile_names() { + let p = provider(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=flat" + )); + assert_eq!( + p.convention_address("my.app", "my.profile", "API_KEY") + .unwrap() + .item, + "/API_KEY" + ); + } + + /// A key carrying a separator is still refused under the flat layout: it + /// would move the secret to another folder. + #[test] + fn flat_layout_still_rejects_a_key_with_a_slash() { + let p = provider(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=flat" + )); + let err = p.convention_address("myapp", "prod", "a/b").unwrap_err(); + assert!(err.to_string().contains("move the secret"), "{err}"); + } + + /// Flat without `?env=` still lets the profile name the environment, so + /// distinct profiles stay apart even sharing the root and key. + #[test] + fn flat_layout_separates_profiles_by_environment() { + let p = provider(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=flat" + )); + let dev = p + .locate(Address::convention("myapp", "dev", "API_KEY")) + .unwrap(); + let prod = p + .locate(Address::convention("myapp", "prod", "API_KEY")) + .unwrap(); + assert_eq!( + (dev.secret_path.as_str(), dev.key.as_str()), + ("/", "API_KEY") + ); + assert_eq!( + (prod.secret_path.as_str(), prod.key.as_str()), + ("/", "API_KEY") + ); + // Same root and key, but different environments keep them apart. + assert_eq!(dev.environment, "dev"); + assert_eq!(prod.environment, "prod"); + } + + /// Flat plus a pinned `?env=` collapses every profile onto one environment + /// root and key -- the deliberate "no profile separation" mode. + #[test] + fn flat_layout_with_pinned_env_collapses_profiles() { + let p = provider(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=flat&env=prod" + )); + let dev = p + .locate(Address::convention("myapp", "dev", "API_KEY")) + .unwrap(); + let prod = p + .locate(Address::convention("myapp", "prod", "API_KEY")) + .unwrap(); + assert_eq!(dev.environment, prod.environment); + assert_eq!(dev.secret_path, prod.secret_path); + assert_eq!(dev.key, prod.key); + } + + /// The default and explicit nested layout are the same store, and neither + /// touches the flat behavior. + #[test] + fn nested_layout_is_the_default() { + let default = config(&format!("infisical://app.infisical.com/{PROJECT}")); + let explicit = config(&format!( + "infisical://app.infisical.com/{PROJECT}?layout=nested" + )); + assert_eq!(default.layout, Layout::Nested); + assert_eq!(explicit.layout, Layout::Nested); + assert_eq!(default.path, "/secretspec"); + assert_eq!(explicit.path, "/secretspec"); + } + + /// An unreadable `layout` is refused rather than silently meaning one of + /// its two values, exactly like `tls`. + #[test] + fn unreadable_layout_is_rejected() { + let err = InfisicalConfig::try_from(&ProviderUrl::new( + Url::parse(&format!("infisical://host/{PROJECT}?layout=banana")).unwrap(), + )) + .unwrap_err(); + assert!(err.to_string().contains("layout value 'banana'"), "{err}"); + } + /// A ref names a folder and key; Infisical values have no components, so /// `field` has no meaning and is rejected rather than ignored. #[test] @@ -1380,6 +1592,11 @@ mod tests { format!("infisical://app.infisical.com/{PROJECT}"), format!("infisical://app.infisical.com/{PROJECT}?env=prod"), format!("infisical://app.infisical.com/{PROJECT}?env=prod&path=/team"), + // Flat at the root renders `layout=flat` but suppresses the default + // `path=/`; an explicit prefix under flat renders both. + format!("infisical://app.infisical.com/{PROJECT}?layout=flat"), + format!("infisical://app.infisical.com/{PROJECT}?layout=flat&path=/team"), + format!("infisical://app.infisical.com/{PROJECT}?env=prod&layout=flat"), format!("infisical://localhost:8080/{PROJECT}?tls=false"), format!("infisical://localhost:8080/{PROJECT}?env=dev&tls=false"), ] { diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index 4865ca5e..bfeeab81 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -65,6 +65,7 @@ use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, percent_encode}; use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashMap; use std::convert::TryFrom; @@ -211,6 +212,56 @@ impl ProviderUrl { pub fn encode_query(value: &str) -> String { percent_encode(value.as_bytes(), QUERY_ENCODE_SET).to_string() } + + /// Parses the general [`?layout=`](Layout) setting: `nested` (the default) + /// or `flat`. Absent or empty, it is [`Layout::Nested`]. + /// + /// An unreadable value is refused rather than guessed, exactly like `?tls=`: + /// the knob is explicit, so a typo in it should be too. Every provider that + /// honors a layout parses it through this one method, so the spelling and + /// the error message are identical across the whole provider set. + pub fn layout(&self) -> Result { + match self.query_value("layout").as_deref() { + None => Ok(Layout::default()), + Some("nested") => Ok(Layout::Nested), + Some("flat") => Ok(Layout::Flat), + Some(other) => Err(SecretSpecError::ProviderOperationFailed(format!( + "Unknown layout value '{other}'. Expected 'nested' or 'flat'." + ))), + } + } +} + +/// How a provider maps SecretSpec's `{project}/{profile}/{key}` naming +/// convention onto its own store. +/// +/// This is a **general provider setting**, not one backend's quirk: every +/// hierarchical store faces the same question, so it is spelled the same way -- +/// `?layout=flat` -- across Infisical, Vault, AWS Secrets Manager, Google Cloud +/// Secret Manager and Azure Key Vault. Providers whose store has no hierarchy +/// (`dotenv`, `env`, `bws`) are already flat and ignore the setting. +/// +/// Available since SecretSpec 0.17. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Layout { + /// Secrets live under `secretspec/{project}/{profile}`, so many projects and + /// profiles share one store without colliding. This is the default, and the + /// shape SecretSpec creates when it writes a store itself. + #[default] + Nested, + /// Secrets sit at the store root -- under any provider-native container (a + /// Vault mount) or user-supplied prefix, but with no + /// `secretspec/{project}/{profile}` scaffolding -- and are addressed by key + /// alone. This is the natural shape of a single-project store, e.g. one + /// migrated from another manager where the secrets already live at the root. + /// + /// Dropping the profile folder means distinct profiles no longer separate + /// themselves by path; a provider that pins its environment another way (an + /// Infisical `?env=`) can still keep them apart, but flat plus a pinned + /// environment collapses every profile onto one key and gives up profile + /// separation deliberately. + Flat, } /// Executes an async future in a blocking context. diff --git a/secretspec/src/provider/vault.rs b/secretspec/src/provider/vault.rs index d2d3293c..c1075eb0 100644 --- a/secretspec/src/provider/vault.rs +++ b/secretspec/src/provider/vault.rs @@ -57,7 +57,7 @@ //! secretspec check --provider vault://team-a@vault.example.com:8200/secret //! ``` -use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; +use super::{Address, Layout, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; use crate::{Result, SecretSpecError}; use reqwest::header::{HeaderMap, HeaderValue}; use secrecy::{ExposeSecret, SecretString}; @@ -102,6 +102,9 @@ pub struct VaultConfig { pub role: Option, /// Audience for the OIDC token minted for JWT authentication (SecretSpec 0.17+). pub audience: Option, + /// How convention secrets map onto KV paths (SecretSpec 0.17+). + #[serde(default)] + pub layout: Layout, } impl Default for VaultConfig { @@ -114,6 +117,7 @@ impl Default for VaultConfig { auth: AuthMethod::default(), role: None, audience: None, + layout: Layout::Nested, } } } @@ -234,6 +238,10 @@ impl TryFrom<&ProviderUrl> for VaultConfig { ))); } + // `layout` is the shared, cross-provider setting, parsed the same way + // everywhere; an unreadable value is refused rather than guessed. + let layout = url.layout()?; + Ok(Self { endpoint, mount, @@ -242,6 +250,7 @@ impl TryFrom<&ProviderUrl> for VaultConfig { auth, role, audience, + layout, }) } } @@ -284,8 +293,26 @@ impl VaultProvider { /// Formats the secret path within the KV engine. /// - /// Uses the pattern: `secretspec/{project}/{profile}/{key}` - fn format_secret_path(project: &str, profile: &str, key: &str) -> Result { + /// Under the nested [`Layout`] the pattern is + /// `secretspec/{project}/{profile}/{key}`. Under the flat layout the + /// scaffolding is dropped and the secret is the `key` itself at the mount + /// root -- the shape a store migrated from elsewhere already has. + fn format_secret_path( + layout: Layout, + project: &str, + profile: &str, + key: &str, + ) -> Result { + if key.is_empty() { + return Err(SecretSpecError::ProviderOperationFailed( + "key cannot be empty".to_string(), + )); + } + // Flat addresses by key alone, so the project and profile that name no + // path segment are not required and are not validated. + if layout == Layout::Flat { + return Ok(key.to_string()); + } if project.is_empty() { return Err(SecretSpecError::ProviderOperationFailed( "project cannot be empty".to_string(), @@ -296,11 +323,6 @@ impl VaultProvider { "profile cannot be empty".to_string(), )); } - if key.is_empty() { - return Err(SecretSpecError::ProviderOperationFailed( - "key cannot be empty".to_string(), - )); - } Ok(format!("secretspec/{}/{}/{}", project, profile, key)) } @@ -658,8 +680,9 @@ impl VaultProvider { } impl Provider for VaultProvider { - /// Convention secrets each live at their own KV path, - /// `secretspec/{project}/{profile}/{key}`, under a `value` field. + /// Convention secrets each live at their own KV path -- by default + /// `secretspec/{project}/{profile}/{key}`, or the `key` alone at the mount + /// root under `?layout=flat` -- under a `value` field. fn convention_address( &self, project: &str, @@ -667,7 +690,7 @@ impl Provider for VaultProvider { key: &str, ) -> Result { Ok(crate::config::NativeAddress { - item: Self::format_secret_path(project, profile, key)?, + item: Self::format_secret_path(self.config.layout, project, profile, key)?, field: Some("value".to_string()), ..Default::default() }) @@ -692,6 +715,9 @@ impl Provider for VaultProvider { uri.push('/'); uri.push_str(&self.config.mount); } + if self.config.layout == Layout::Flat { + uri.push_str("?layout=flat"); + } uri } @@ -802,4 +828,50 @@ mod reference_tests { let err = p.get(Address::Native(&addr)).unwrap_err(); assert!(err.to_string().contains("`version`"), "{err}"); } + + /// The flat layout drops the `secretspec/{project}/{profile}` scaffolding, + /// so a convention secret is the key itself at the mount root. + #[test] + fn flat_layout_addresses_the_key_at_the_mount_root() { + let c = config("vault://vault.example.com:8200/secret?layout=flat"); + assert_eq!(c.layout, Layout::Flat); + let addr = VaultProvider::new(c) + .convention_address("myapp", "prod", "API_KEY") + .unwrap(); + assert_eq!(addr.item, "API_KEY"); + assert_eq!(addr.field.as_deref(), Some("value")); + } + + /// Flat addresses by key alone, so a project or profile that names no path + /// segment is not required. + #[test] + fn flat_layout_does_not_require_project_or_profile() { + let p = VaultProvider::new(config("vault://vault.example.com:8200/secret?layout=flat")); + assert_eq!( + p.convention_address("", "", "API_KEY").unwrap().item, + "API_KEY" + ); + } + + /// `?layout=flat` survives the round-trip through `uri()`, while the + /// default nested layout stays unspelled. + #[test] + fn flat_layout_round_trips_through_uri() { + let p = VaultProvider::new(config("vault://vault.example.com:8200/secret?layout=flat")); + assert!(p.uri().ends_with("?layout=flat"), "{}", p.uri()); + assert_eq!(config(&p.uri()).layout, Layout::Flat); + + let nested = VaultProvider::new(config("vault://vault.example.com:8200/secret")); + assert!(!nested.uri().contains("layout"), "{}", nested.uri()); + } + + /// An unreadable layout is refused rather than guessed. + #[test] + fn unreadable_layout_is_rejected() { + let err = VaultConfig::try_from(&ProviderUrl::new( + Url::parse("vault://vault.example.com:8200/secret?layout=banana").unwrap(), + )) + .unwrap_err(); + assert!(err.to_string().contains("layout value 'banana'"), "{err}"); + } }