Skip to content

Commit b392b2e

Browse files
authored
feat(providersv2): add path auth_style (#1622)
Signed-off-by: Calum Murray <cmurray@redhat.com>
1 parent 13e8318 commit b392b2e

5 files changed

Lines changed: 110 additions & 3 deletions

File tree

crates/openshell-providers/src/discovery.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ mod tests {
9696
header_name: String::new(),
9797
query_param: String::new(),
9898
refresh: None,
99+
path_template: String::new(),
99100
},
100101
CredentialProfile {
101102
name: "secondary".to_string(),
@@ -106,6 +107,7 @@ mod tests {
106107
header_name: String::new(),
107108
query_param: String::new(),
108109
refresh: None,
110+
path_template: String::new(),
109111
},
110112
],
111113
endpoints: Vec::new(),

crates/openshell-providers/src/profiles.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
1616
use std::collections::{HashMap, HashSet};
1717
use std::sync::OnceLock;
1818

19+
const PATH_TEMPLATE_CREDENTIAL_PLACEHOLDER: &str = "{credential}";
20+
1921
const BUILT_IN_PROFILE_YAMLS: &[&str] = &[
2022
include_str!("../../../providers/claude-code.yaml"),
2123
include_str!("../../../providers/codex.yaml"),
@@ -86,6 +88,8 @@ pub struct CredentialProfile {
8688
pub query_param: String,
8789
#[serde(default, skip_serializing_if = "Option::is_none")]
8890
pub refresh: Option<CredentialRefreshProfile>,
91+
#[serde(default, skip_serializing_if = "String::is_empty")]
92+
pub path_template: String,
8993
}
9094

9195
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
@@ -285,6 +289,7 @@ impl ProviderTypeProfile {
285289
.refresh
286290
.as_ref()
287291
.map(credential_refresh_from_proto),
292+
path_template: credential.path_template.clone(),
288293
})
289294
.collect(),
290295
endpoints: profile.endpoints.iter().map(endpoint_from_proto).collect(),
@@ -349,6 +354,7 @@ impl ProviderTypeProfile {
349354
header_name: credential.header_name.clone(),
350355
query_param: credential.query_param.clone(),
351356
refresh: credential.refresh.as_ref().map(credential_refresh_to_proto),
357+
path_template: credential.path_template.clone(),
352358
})
353359
.collect(),
354360
endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(),
@@ -1003,6 +1009,31 @@ pub fn validate_profile_set(
10031009
));
10041010
}
10051011
}
1012+
"path" => {
1013+
let path_template = credential.path_template.trim();
1014+
if path_template.is_empty() {
1015+
diagnostics.push(ProfileValidationDiagnostic::error(
1016+
source,
1017+
profile_id,
1018+
"credentials.path_template",
1019+
"path_template is required for path auth",
1020+
));
1021+
} else {
1022+
let count = path_template
1023+
.matches(PATH_TEMPLATE_CREDENTIAL_PLACEHOLDER)
1024+
.count();
1025+
if count != 1 {
1026+
diagnostics.push(ProfileValidationDiagnostic::error(
1027+
source,
1028+
profile_id,
1029+
"credentials.path_template",
1030+
format!(
1031+
"path_template should contain {{credential}} exactly once, {path_template} contains {{credential}} {count} times",
1032+
),
1033+
));
1034+
}
1035+
}
1036+
}
10061037
"query" => {
10071038
if credential.query_param.trim().is_empty() {
10081039
diagnostics.push(ProfileValidationDiagnostic::error(
@@ -1351,6 +1382,63 @@ credentials:
13511382
assert!(exported.contains("client_secret"));
13521383
}
13531384

1385+
#[test]
1386+
fn credential_fields_round_trip_through_proto_and_yaml() {
1387+
let profile = parse_profile_yaml(
1388+
r"
1389+
id: multi-auth
1390+
display_name: Multi Auth
1391+
credentials:
1392+
- name: basic_cred
1393+
env_vars: [BASIC_TOKEN]
1394+
auth_style: basic
1395+
- name: bearer_cred
1396+
env_vars: [BEARER_TOKEN]
1397+
auth_style: bearer
1398+
header_name: authorization
1399+
- name: query_cred
1400+
env_vars: [QUERY_TOKEN]
1401+
auth_style: query
1402+
query_param: api_key
1403+
- name: path_cred
1404+
env_vars: [PATH_TOKEN]
1405+
auth_style: path
1406+
path_template: /v1/{credential}/resources
1407+
",
1408+
)
1409+
.expect("profile should parse");
1410+
1411+
let diagnostics = validate_profile_set(&[("multi-auth.yaml".to_string(), profile.clone())]);
1412+
assert!(
1413+
diagnostics.is_empty(),
1414+
"unexpected diagnostics: {diagnostics:?}"
1415+
);
1416+
1417+
assert_eq!(profile.credentials[1].header_name, "authorization");
1418+
assert_eq!(profile.credentials[2].query_param, "api_key");
1419+
assert_eq!(
1420+
profile.credentials[3].path_template,
1421+
"/v1/{credential}/resources"
1422+
);
1423+
1424+
let from_proto = ProviderTypeProfile::from_proto(&profile.to_proto());
1425+
assert_eq!(from_proto.credentials[1].header_name, "authorization");
1426+
assert_eq!(from_proto.credentials[2].query_param, "api_key");
1427+
assert_eq!(
1428+
from_proto.credentials[3].path_template,
1429+
"/v1/{credential}/resources"
1430+
);
1431+
1432+
let exported = profile_to_yaml(&from_proto).expect("yaml");
1433+
let reparsed = parse_profile_yaml(&exported).expect("re-parse");
1434+
assert_eq!(reparsed.credentials[1].header_name, "authorization");
1435+
assert_eq!(reparsed.credentials[2].query_param, "api_key");
1436+
assert_eq!(
1437+
reparsed.credentials[3].path_template,
1438+
"/v1/{credential}/resources"
1439+
);
1440+
}
1441+
13541442
#[test]
13551443
fn profile_json_round_trip_preserves_compact_dto_shape() {
13561444
let profile = get_default_profile("github").expect("github profile");
@@ -1458,6 +1546,13 @@ credentials:
14581546
- name: api_key
14591547
env_vars: [BROKEN_TOKEN, ""]
14601548
auth_style: unknown
1549+
- name: path_key
1550+
env_vars: [PATH_TOKEN]
1551+
auth_style: path
1552+
- name: path_key_bad
1553+
env_vars: [PATH_TOKEN_BAD]
1554+
auth_style: path
1555+
path_template: /v1/{key}/resources
14611556
discovery:
14621557
credentials: [api_key, missing_key]
14631558
endpoints:
@@ -1478,6 +1573,11 @@ binaries: ["", /usr/bin/broken]
14781573
assert!(messages.contains(&"duplicate credential env var 'BROKEN_TOKEN'"));
14791574
assert!(messages.contains(&"credential env var must not be empty"));
14801575
assert!(messages.contains(&"query_param is required for query auth"));
1576+
assert!(messages.contains(&"path_template is required for path auth"));
1577+
assert!(messages.iter().any(|message| {
1578+
message.contains("should contain {credential} exactly once")
1579+
&& message.contains("0 times")
1580+
}));
14811581
assert!(messages.contains(&"unsupported auth_style: unknown"));
14821582
assert!(messages.contains(&"unknown discovery credential: missing_key"));
14831583
assert!(

crates/openshell-server/src/grpc/provider.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,6 +1737,7 @@ mod tests {
17371737
auth_style: "bearer".to_string(),
17381738
header_name: "authorization".to_string(),
17391739
query_param: String::new(),
1740+
path_template: String::new(),
17401741
refresh: Some(ProviderCredentialRefresh {
17411742
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32,
17421743
token_url: "https://auth.example.com/token".to_string(),
@@ -1794,6 +1795,7 @@ mod tests {
17941795
header_name: "authorization".to_string(),
17951796
query_param: String::new(),
17961797
refresh: None,
1798+
path_template: String::new(),
17971799
}
17981800
}
17991801

@@ -3202,6 +3204,7 @@ mod tests {
32023204
auth_style: "bearer".to_string(),
32033205
header_name: "authorization".to_string(),
32043206
query_param: String::new(),
3207+
path_template: String::new(),
32053208
refresh: Some(ProviderCredentialRefresh {
32063209
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken
32073210
as i32,

docs/sandboxes/providers-v2.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ The following Providers v2 design items are not part of the current behavior:
6767

6868
| Roadmap item | Current behavior |
6969
|---|---|
70-
| Profile-driven explicit credential injection | Profile `auth_style`, `header_name`, and `query_param` fields are stored and validated, but runtime injection still depends on environment placeholders generated from provider credentials. |
70+
| Profile-driven explicit credential injection | Profile `auth_style`, `header_name`, `query_param`, and `path_template` fields are stored and validated, but runtime injection still depends on environment placeholders generated from provider credentials. |
7171
| Endpoint and binary scoped credential injection | Provider profile endpoints and binaries affect policy composition. They do not yet restrict which outbound requests can receive credential injection. |
7272
| Credential verification on create | `openshell provider create` does not yet probe provider verification endpoints or expose `--no-verify`. |
7373
| Automatic credential scope extraction | OpenShell does not yet inspect upstream provider responses to discover credential scopes. |
@@ -158,12 +158,13 @@ credentials:
158158
env_vars: [CUSTOM_API_TOKEN]
159159
required: true
160160

161-
# Accepted values: basic, bearer, header, query.
161+
# Accepted values: basic, bearer, header, query, path.
162162
# These fields describe the intended credential placement.
163163
# Runtime injection still uses env placeholder resolution today.
164164
auth_style: bearer
165165
header_name: authorization
166166
query_param: api_key
167+
path_template: /v1/{credential}/resources
167168

168169
refresh:
169170
# Accepted values:
@@ -239,7 +240,7 @@ binaries:
239240

240241
`category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum.
241242

242-
`credentials` declares the credential names, environment variables, auth metadata, and optional refresh metadata for the provider type. The current runtime still exposes configured credential keys as placeholder environment variables and resolves placeholders in outbound HTTP requests.
243+
`credentials` declares the credential names, environment variables, auth metadata, and optional refresh metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). The current runtime still exposes configured credential keys as placeholder environment variables and resolves placeholders in outbound HTTP requests.
243244

244245
`discovery` controls what `--from-existing` scans when
245246
`providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a

proto/openshell.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,7 @@ message ProviderProfileCredential {
902902
string header_name = 6;
903903
string query_param = 7;
904904
ProviderCredentialRefresh refresh = 8;
905+
string path_template = 9;
905906
}
906907

907908
enum ProviderCredentialRefreshStrategy {

0 commit comments

Comments
 (0)