Skip to content

Commit 760e755

Browse files
domenkozarclaude
andcommitted
feat: add folder_prefix support to keyring and pass providers
Allow sharing secrets across projects by customizing the storage path via URI (e.g., keyring://secretspec/shared/{profile}/{key}), matching the existing OnePassword and LastPass behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent af98996 commit 760e755

File tree

6 files changed

+182
-59
lines changed

6 files changed

+182
-59
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Keyring and pass providers now support `folder_prefix` via URI (e.g., `keyring://secretspec/shared/{profile}/{key}`)
12+
to share secrets across projects, matching the existing OnePassword and LastPass behavior
13+
1014
### Changed
1115
- Support `XDG_CONFIG_HOME` on macOS by switching from `directories` to `etcetera` crate.
1216
Existing macOS configs at `~/Library/Application Support/secretspec/` are automatically

docs/src/content/docs/providers/keyring.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,24 @@ $ secretspec run -- npm start
5555
# Use with profiles
5656
$ secretspec set API_KEY --profile production
5757
$ secretspec run --profile production -- npm start
58-
```
58+
```
59+
60+
## Shared Secrets
61+
62+
By default, secrets are stored under `secretspec/{project}/{profile}/{key}`, which isolates them per project. To share secrets across projects, use a custom folder prefix via the URI:
63+
64+
```toml
65+
# ~/.config/secretspec/config.toml
66+
[providers]
67+
shared = "keyring://secretspec/shared/{profile}/{key}"
68+
```
69+
70+
The URI supports `{project}`, `{profile}`, and `{key}` placeholders. By omitting `{project}`, multiple projects can read and write the same keyring entry:
71+
72+
```toml
73+
# secretspec.toml (in project-A and project-B)
74+
[profiles.default]
75+
ARTIFACTORY_USER = { description = "Artifactory user", providers = ["shared"] }
76+
```
77+
78+
Both projects will resolve `ARTIFACTORY_USER` from keyring service `secretspec/shared/default/ARTIFACTORY_USER`.

docs/src/content/docs/providers/pass.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,23 @@ For example, with project "myapp" and profile "default":
5757
$ pass show secretspec/myapp/default/DATABASE_URL
5858
postgresql://localhost/mydb
5959
```
60+
61+
## Shared Secrets
62+
63+
By default, secrets are stored under `secretspec/{project}/{profile}/{key}`, which isolates them per project. To share secrets across projects, use a custom folder prefix via the URI:
64+
65+
```toml
66+
# ~/.config/secretspec/config.toml
67+
[providers]
68+
shared = "pass://secretspec/shared/{profile}/{key}"
69+
```
70+
71+
The URI supports `{project}`, `{profile}`, and `{key}` placeholders. By omitting `{project}`, multiple projects can read and write the same pass entry:
72+
73+
```toml
74+
# secretspec.toml (in project-A and project-B)
75+
[profiles.default]
76+
ARTIFACTORY_USER = { description = "Artifactory user", providers = ["shared"] }
77+
```
78+
79+
Both projects will resolve `ARTIFACTORY_USER` from pass entry `secretspec/shared/default/ARTIFACTORY_USER`.

secretspec/src/provider/keyring.rs

Lines changed: 60 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,30 @@ use url::Url;
99
///
1010
/// This struct holds configuration options for the keyring provider,
1111
/// which stores secrets in the system's native keychain service.
12-
/// Currently, no additional configuration is required as the provider
13-
/// uses sensible defaults for all platforms.
14-
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15-
pub struct KeyringConfig {}
12+
#[derive(Debug, Clone, Serialize, Deserialize)]
13+
pub struct KeyringConfig {
14+
/// Optional folder prefix format string for organizing secrets in the keyring.
15+
///
16+
/// Supports placeholders: {project}, {profile}, and {key}.
17+
/// Defaults to "secretspec/{project}/{profile}/{key}" if not specified.
18+
pub folder_prefix: Option<String>,
19+
}
20+
21+
impl Default for KeyringConfig {
22+
fn default() -> Self {
23+
Self {
24+
folder_prefix: None,
25+
}
26+
}
27+
}
1628

1729
impl TryFrom<&Url> for KeyringConfig {
1830
type Error = SecretSpecError;
1931

2032
/// Creates a new KeyringConfig from a URL.
2133
///
22-
/// The URL must have the scheme "keyring" (e.g., "keyring://").
23-
/// Currently, no additional parameters are supported in the URL.
34+
/// The URL must have the scheme "keyring" (e.g., "keyring://" or
35+
/// "keyring://secretspec/shared/{profile}/{key}").
2436
///
2537
/// # Examples
2638
///
@@ -38,12 +50,20 @@ impl TryFrom<&Url> for KeyringConfig {
3850
)));
3951
}
4052

41-
Ok(Self::default())
53+
let mut config = Self::default();
54+
55+
if let Some(host) = url.host_str() {
56+
let path = url.path();
57+
// Percent-decode so placeholders like {profile} and {key} survive URL parsing
58+
let raw = format!("{}{}", host, path);
59+
let decoded = raw.replace("%7B", "{").replace("%7D", "}");
60+
config.folder_prefix = Some(decoded);
61+
}
62+
63+
Ok(config)
4264
}
4365
}
4466

45-
impl KeyringConfig {}
46-
4767
/// Provider for storing secrets in the system keychain.
4868
///
4969
/// The KeyringProvider uses the operating system's native secure credential
@@ -52,13 +72,12 @@ impl KeyringConfig {}
5272
/// - Windows: Credential Manager
5373
/// - Linux: Secret Service API (via libsecret)
5474
///
55-
/// Secrets are stored with a hierarchical key structure:
56-
/// `secretspec/{project}/{profile}/{key}`
75+
/// Secrets are stored with a hierarchical key structure using a configurable
76+
/// format string that defaults to: `secretspec/{project}/{profile}/{key}`.
5777
///
5878
/// This ensures secrets are properly namespaced by project and profile,
5979
/// preventing conflicts between different projects or environments.
6080
pub struct KeyringProvider {
61-
#[allow(dead_code)]
6281
config: KeyringConfig,
6382
}
6483

@@ -68,7 +87,7 @@ crate::register_provider! {
6887
name: "keyring",
6988
description: "Uses system keychain (Recommended)",
7089
schemes: ["keyring"],
71-
examples: ["keyring://"],
90+
examples: ["keyring://", "keyring://secretspec/shared/{profile}/{key}"],
7291
}
7392

7493
impl KeyringProvider {
@@ -84,6 +103,23 @@ impl KeyringProvider {
84103
pub fn new(config: KeyringConfig) -> Self {
85104
Self { config }
86105
}
106+
107+
/// Formats the service name for a secret in the keyring.
108+
///
109+
/// Uses folder_prefix as a format string with {project}, {profile}, and {key} placeholders.
110+
/// Defaults to "secretspec/{project}/{profile}/{key}" if not configured.
111+
fn format_service(&self, project: &str, profile: &str, key: &str) -> String {
112+
let format_string = self
113+
.config
114+
.folder_prefix
115+
.as_deref()
116+
.unwrap_or("secretspec/{project}/{profile}/{key}");
117+
118+
format_string
119+
.replace("{project}", project)
120+
.replace("{profile}", profile)
121+
.replace("{key}", key)
122+
}
87123
}
88124

89125
impl Provider for KeyringProvider {
@@ -92,30 +128,21 @@ impl Provider for KeyringProvider {
92128
}
93129

94130
fn uri(&self) -> String {
95-
// Keyring can be just "keyring" or "keyring://"
96-
"keyring".to_string()
131+
if let Some(ref prefix) = self.config.folder_prefix {
132+
format!("keyring://{}", prefix)
133+
} else {
134+
"keyring".to_string()
135+
}
97136
}
98137

99138
/// Retrieves a secret from the system keychain.
100139
///
101-
/// The secret is looked up using a hierarchical key structure:
102-
/// `secretspec/{project}/{profile}/{key}`
140+
/// The secret is looked up using a hierarchical key structure determined
141+
/// by the folder_prefix format string (defaults to `secretspec/{project}/{profile}/{key}`).
103142
///
104143
/// The current system username is used as the account identifier.
105-
///
106-
/// # Arguments
107-
///
108-
/// * `project` - The project name
109-
/// * `key` - The secret key to retrieve
110-
/// * `profile` - The profile/environment name
111-
///
112-
/// # Returns
113-
///
114-
/// * `Ok(Some(String))` - The secret value if found
115-
/// * `Ok(None)` - If the secret doesn't exist
116-
/// * `Err` - If there was an error accessing the keychain
117144
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
118-
let service = format!("secretspec/{}/{}/{}", project, profile, key);
145+
let service = self.format_service(project, profile, key);
119146
let username = whoami::username()
120147
.map_err(|e| SecretSpecError::ProviderOperationFailed(e.to_string()))?;
121148
let entry = Entry::new(&service, &username)?;
@@ -128,25 +155,13 @@ impl Provider for KeyringProvider {
128155

129156
/// Stores a secret in the system keychain.
130157
///
131-
/// The secret is stored with a hierarchical key structure:
132-
/// `secretspec/{project}/{profile}/{key}`
158+
/// The secret is stored with a hierarchical key structure determined
159+
/// by the folder_prefix format string (defaults to `secretspec/{project}/{profile}/{key}`).
133160
///
134161
/// The current system username is used as the account identifier.
135162
/// If a secret already exists with the same key, it will be overwritten.
136-
///
137-
/// # Arguments
138-
///
139-
/// * `project` - The project name
140-
/// * `key` - The secret key to store
141-
/// * `value` - The secret value to store
142-
/// * `profile` - The profile/environment name
143-
///
144-
/// # Returns
145-
///
146-
/// * `Ok(())` - If the secret was stored successfully
147-
/// * `Err` - If there was an error accessing the keychain
148163
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
149-
let service = format!("secretspec/{}/{}/{}", project, profile, key);
164+
let service = self.format_service(project, profile, key);
150165
let username = whoami::username()
151166
.map_err(|e| SecretSpecError::ProviderOperationFailed(e.to_string()))?;
152167
let entry = Entry::new(&service, &username)?;

secretspec/src/provider/pass.rs

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,31 @@ use url::Url;
99
///
1010
/// This struct holds configuration options for the pass provider.
1111
/// Pass stores secrets as GPG-encrypted files using the Unix password
12-
/// manager in a hierarchical structure: `secretspec/{project}/{profile}/{key}`.
13-
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14-
pub struct PassConfig {}
12+
/// manager in a hierarchical structure.
13+
#[derive(Debug, Clone, Serialize, Deserialize)]
14+
pub struct PassConfig {
15+
/// Optional folder prefix format string for organizing secrets in pass.
16+
///
17+
/// Supports placeholders: {project}, {profile}, and {key}.
18+
/// Defaults to "secretspec/{project}/{profile}/{key}" if not specified.
19+
pub folder_prefix: Option<String>,
20+
}
21+
22+
impl Default for PassConfig {
23+
fn default() -> Self {
24+
Self {
25+
folder_prefix: None,
26+
}
27+
}
28+
}
1529

1630
impl TryFrom<&Url> for PassConfig {
1731
type Error = SecretSpecError;
1832

1933
/// Creates a PassConfig from a URL.
2034
///
21-
/// The URL must have the scheme "pass" (e.g., "pass://").
22-
/// Currently, no additional parameters are supported in the URL.
35+
/// The URL must have the scheme "pass" (e.g., "pass://" or
36+
/// "pass://secretspec/shared/{profile}/{key}").
2337
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
2438
if url.scheme() != "pass" {
2539
return Err(SecretSpecError::ProviderOperationFailed(format!(
@@ -28,7 +42,17 @@ impl TryFrom<&Url> for PassConfig {
2842
)));
2943
}
3044

31-
Ok(Self::default())
45+
let mut config = Self::default();
46+
47+
if let Some(host) = url.host_str() {
48+
let path = url.path();
49+
// Percent-decode so placeholders like {profile} and {key} survive URL parsing
50+
let raw = format!("{}{}", host, path);
51+
let decoded = raw.replace("%7B", "{").replace("%7D", "}");
52+
config.folder_prefix = Some(decoded);
53+
}
54+
55+
Ok(config)
3256
}
3357
}
3458

@@ -40,8 +64,6 @@ impl TryFrom<Url> for PassConfig {
4064
}
4165
}
4266

43-
impl PassConfig {}
44-
4567
/// Provider for managing secrets with pass (password-store).
4668
///
4769
/// The PassProvider uses the Unix password manager `pass`, which stores
@@ -61,7 +83,6 @@ impl PassConfig {}
6183
/// - GPG must be configured with appropriate keys
6284
/// - The password store must be initialized (`pass init`)
6385
pub struct PassProvider {
64-
#[allow(dead_code)]
6586
config: PassConfig,
6687
}
6788

@@ -71,7 +92,7 @@ crate::register_provider! {
7192
name: "pass",
7293
description: "Unix password manager with GPG encryption",
7394
schemes: ["pass"],
74-
examples: ["pass://"],
95+
examples: ["pass://", "pass://secretspec/shared/{profile}/{key}"],
7596
}
7697

7798
impl PassProvider {
@@ -82,9 +103,19 @@ impl PassProvider {
82103

83104
/// Formats the entry name for a secret.
84105
///
85-
/// Creates a hierarchical path: `secretspec/{project}/{profile}/{key}`
106+
/// Uses folder_prefix as a format string with {project}, {profile}, and {key} placeholders.
107+
/// Defaults to "secretspec/{project}/{profile}/{key}" if not configured.
86108
fn format_entry_name(&self, project: &str, profile: &str, key: &str) -> String {
87-
format!("secretspec/{}/{}/{}", project, profile, key)
109+
let format_string = self
110+
.config
111+
.folder_prefix
112+
.as_deref()
113+
.unwrap_or("secretspec/{project}/{profile}/{key}");
114+
115+
format_string
116+
.replace("{project}", project)
117+
.replace("{profile}", profile)
118+
.replace("{key}", key)
88119
}
89120
}
90121

@@ -94,7 +125,11 @@ impl Provider for PassProvider {
94125
}
95126

96127
fn uri(&self) -> String {
97-
"pass".to_string()
128+
if let Some(ref prefix) = self.config.folder_prefix {
129+
format!("pass://{}", prefix)
130+
} else {
131+
"pass".to_string()
132+
}
98133
}
99134

100135
/// Retrieves a secret from the password store.

secretspec/src/provider/tests.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,35 @@ mod integration_tests {
452452
assert_eq!(provider.uri(), "pass");
453453
}
454454

455+
#[test]
456+
fn test_keyring_with_folder_prefix() {
457+
let provider =
458+
Box::<dyn Provider>::try_from("keyring://secretspec/shared/{profile}/{key}").unwrap();
459+
assert_eq!(provider.name(), "keyring");
460+
assert_eq!(
461+
provider.uri(),
462+
"keyring://secretspec/shared/{profile}/{key}"
463+
);
464+
465+
// Without folder_prefix, should use default URI
466+
let provider = Box::<dyn Provider>::try_from("keyring://").unwrap();
467+
assert_eq!(provider.name(), "keyring");
468+
assert_eq!(provider.uri(), "keyring");
469+
}
470+
471+
#[test]
472+
fn test_pass_with_folder_prefix() {
473+
let provider =
474+
Box::<dyn Provider>::try_from("pass://secretspec/shared/{profile}/{key}").unwrap();
475+
assert_eq!(provider.name(), "pass");
476+
assert_eq!(provider.uri(), "pass://secretspec/shared/{profile}/{key}");
477+
478+
// Without folder_prefix, should use default URI
479+
let provider = Box::<dyn Provider>::try_from("pass://").unwrap();
480+
assert_eq!(provider.name(), "pass");
481+
assert_eq!(provider.uri(), "pass");
482+
}
483+
455484
#[test]
456485
fn test_pass_provider_allows_set() {
457486
let provider = Box::<dyn Provider>::try_from("pass").unwrap();

0 commit comments

Comments
 (0)