Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 130 additions & 6 deletions crates/openshell-server/src/grpc/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2127,6 +2127,7 @@ fn provider_credential_config_key_collision(
struct DynamicTokenGrantBinding {
provider_name: String,
credential_name: String,
header_name: String,
host: String,
port: u32,
path: String,
Expand Down Expand Up @@ -2182,7 +2183,7 @@ fn push_dynamic_token_grant_bindings_for_endpoint(
push_dynamic_token_grant_binding(
bindings,
provider_name,
&credential.name,
credential,
endpoint_host,
endpoint_port,
endpoint_path,
Expand Down Expand Up @@ -2214,7 +2215,7 @@ fn push_dynamic_token_grant_bindings_for_endpoint(
push_dynamic_token_grant_binding(
bindings,
provider_name,
&credential.name,
credential,
override_host,
override_port,
override_path,
Expand All @@ -2225,14 +2226,21 @@ fn push_dynamic_token_grant_bindings_for_endpoint(
fn push_dynamic_token_grant_binding(
bindings: &mut Vec<DynamicTokenGrantBinding>,
provider_name: &str,
credential_name: &str,
credential: &ProviderProfileCredential,
host: &str,
port: u32,
path: &str,
) {
let candidate = DynamicTokenGrantBinding {
provider_name: provider_name.to_string(),
credential_name: credential_name.to_string(),
credential_name: credential.name.clone(),
// The supervisor selects one grant per case-insensitive header, using
// Authorization when bearer placement omits an explicit destination.
header_name: if credential.header_name.trim().is_empty() {
"authorization".to_string()
} else {
credential.header_name.trim().to_ascii_lowercase()
},
host: host.to_ascii_lowercase(),
port,
path: path.to_string(),
Expand All @@ -2253,7 +2261,8 @@ fn validate_dynamic_token_grant_bindings_unambiguous(
{
continue;
}
if first.port == second.port
if first.header_name == second.header_name
&& first.port == second.port
&& first.score == second.score
&& host_patterns_can_overlap(&first.host, &second.host)
&& path_patterns_can_overlap(&first.path, &second.path)
Expand Down Expand Up @@ -5456,9 +5465,28 @@ mod tests {
host: &str,
port: u32,
path: &str,
) {
import_token_grant_profile_with_credentials(
state,
id,
host,
port,
path,
vec![token_grant_credential("access_token")],
)
.await;
}

async fn import_token_grant_profile_with_credentials(
state: &Arc<ServerState>,
id: &str,
host: &str,
port: u32,
path: &str,
credentials: Vec<ProviderProfileCredential>,
) {
let mut profile = custom_profile(id);
profile.credentials = vec![token_grant_credential("access_token")];
profile.credentials = credentials;
profile.endpoints = vec![NetworkEndpoint {
host: host.to_string(),
port,
Expand Down Expand Up @@ -5515,6 +5543,102 @@ mod tests {
.unwrap()
}

#[tokio::test]
async fn dynamic_token_grants_allow_distinct_headers_in_one_profile() {
let state = test_server_state().await;
let store = state.store.as_ref();
let service = token_grant_credential("service");
let mut identity = token_grant_credential("identity");
identity.auth_style = "header".into();
identity.header_name = "X-Workload-Jwt".into();
let grant = identity.token_grant.as_mut().unwrap();
grant.token_endpoint = "https://identity.example.com/token".into();
grant.jwt_svid_audience = "identity-proxy".into();
grant.audience = "workload".into();
grant.scopes = vec!["identity.read".into()];
grant.cache_ttl = Some(prost_types::Duration {
seconds: 45,
nanos: 0,
});
import_token_grant_profile_with_credentials(
&state,
"grant-pair",
"api.example.com",
443,
"/v1/**",
vec![service.clone(), identity.clone()],
)
.await;
create_empty_token_grant_provider(store, "provider", "grant-pair").await;
validate_provider_environment_keys_unique(store, "default", &["provider".into()])
.await
.expect("distinct headers must compose in one provider");

let catalog = ProviderProfileSources::with_default_sources()
.snapshot_catalog(store, "default")
.await
.unwrap();
let profile = get_provider_type_profile_for_scope(&catalog, "grant-pair", "default")
.unwrap()
.to_proto();
let mut credentials = HashMap::new();
insert_dynamic_credentials_for_profile(&mut credentials, &profile, "provider");
assert_eq!(credentials.len(), 2);
for expected in [service, identity] {
let key = dynamic_credential_key(
"api.example.com",
443,
"/v1/**",
"provider",
&expected.name,
);
assert_eq!(credentials[&key].token_grant, expected.token_grant);
assert_eq!(credentials[&key].header_name, expected.header_name);
assert!(credentials[&key].env_vars.is_empty());
}
}

#[test]
fn dynamic_token_grants_reject_normalized_header_collisions() {
for header in ["Authorization", " authorization ", ""] {
let mut profile = custom_profile("grant-pair");
let mut second = token_grant_credential("second");
second.header_name = header.into();
profile.credentials = vec![token_grant_credential("first"), second];
profile.endpoints = vec![NetworkEndpoint {
host: "api.example.com".into(),
port: 443,
path: "/v1/**".into(),
..Default::default()
}];
let bindings = dynamic_token_grant_bindings_for_profile("provider", &profile);
assert_eq!(bindings.len(), 2);
assert!(validate_dynamic_token_grant_bindings_unambiguous(&bindings).is_err());
}
}

#[test]
fn dynamic_token_grants_allow_distinct_headers_across_providers() {
let mut first = custom_profile("grant-a");
first.credentials = vec![token_grant_credential("service")];
first.endpoints = vec![NetworkEndpoint {
host: "api.example.com".into(),
port: 443,
path: "/v1/**".into(),
..Default::default()
}];
let mut second = first.clone();
second.credentials[0].auth_style = "header".into();
second.credentials[0].header_name = "X-Workload-Jwt".into();
let mut bindings = dynamic_token_grant_bindings_for_profile("provider-a", &first);
bindings.extend(dynamic_token_grant_bindings_for_profile(
"provider-b",
&second,
));
validate_dynamic_token_grant_bindings_unambiguous(&bindings)
.expect("different headers must not be treated as alternatives");
}

#[tokio::test]
async fn dynamic_token_grants_reject_equal_specificity_overlap() {
let state = test_server_state().await;
Expand Down
124 changes: 124 additions & 0 deletions crates/openshell-supervisor-network/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4994,6 +4994,130 @@ network_policies:
);
}

#[tokio::test]
async fn l7_rest_tls_relay_injects_multiple_grants() {
assert_multiple_grants_tls_relay(Ok("identity-token")).await;
}

#[tokio::test]
async fn l7_rest_tls_relay_second_grant_failure_forwards_nothing() {
assert_multiple_grants_tls_relay(Err("issuer echoed identity-secret")).await;
}

async fn token_grant_tls_pair() -> (
tokio_rustls::client::TlsStream<tokio::io::DuplexStream>,
tokio_rustls::server::TlsStream<tokio::io::DuplexStream>,
) {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let key = rcgen::KeyPair::generate().unwrap();
let cert = rcgen::CertificateParams::new(vec!["api.example.test".into()])
.unwrap()
.self_signed(&key)
.unwrap();
let server_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![cert.der().clone()],
rustls::pki_types::PrivatePkcs8KeyDer::from(key.serialize_der()).into(),
)
.unwrap();
let mut roots = rustls::RootCertStore::empty();
roots.add(cert.der().clone()).unwrap();
let client_config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
let (client, server) = tokio::io::duplex(16384);
let (client, server) = tokio::join!(
connector.connect("api.example.test".try_into().unwrap(), client),
acceptor.accept(server),
);
(client.unwrap(), server.unwrap())
}

async fn assert_multiple_grants_tls_relay(identity_result: std::result::Result<&str, &str>) {
let (config, tunnel_engine, ctx, fixture) =
rest_token_grant_relay_context(Ok("service-token"));
let service_key = "api.example.test\t8080\t/v1/**\tprovider:access_token";
let identity_key = "api.example.test\t8080\t/v1/**\tprovider:identity";
let mut identity = fixture.dynamic_credentials().read().unwrap()[service_key].clone();
identity.name = "identity".into();
identity.auth_style = "header".into();
identity.header_name = "X-Workload-Jwt".into();
fixture.add_credential(identity_key, identity, identity_result);
// Both sides verify a synthetic certificate: the test exercises encrypted
// application traffic, inspection and credential injection, then upstream TLS.
let (mut app, mut relay_client) = token_grant_tls_pair().await;
let (mut relay_upstream, mut upstream) = token_grant_tls_pair().await;
let relay = tokio::spawn(async move {
relay_with_inspection(
&config,
tunnel_engine,
&mut relay_client,
&mut relay_upstream,
&ctx,
)
.await
});
app.write_all(b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer agent-token\r\nX-Workload-Jwt: agent-identity\r\nConnection: close\r\n\r\n")
.await.unwrap();
if identity_result.is_ok() {
let mut request = [0u8; 2048];
let n = tokio::time::timeout(
std::time::Duration::from_secs(2),
upstream.read(&mut request),
)
.await
.unwrap()
.unwrap();
let request = String::from_utf8_lossy(&request[..n]);
assert!(request.contains("Authorization: Bearer service-token\r\n"));
assert!(request.contains("X-Workload-Jwt: identity-token\r\n"));
assert!(!request.contains("agent-token"));
assert!(!request.contains("agent-identity"));
upstream
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
}
let mut response = [0u8; 1024];
let n = tokio::time::timeout(std::time::Duration::from_secs(2), app.read(&mut response))
.await
.unwrap()
.unwrap();
let response = String::from_utf8_lossy(&response[..n]);
assert!(response.contains(if identity_result.is_ok() {
"204 No Content"
} else {
"502 Bad Gateway"
}));
assert!(!response.contains("service-token"));
assert!(!response.contains("identity-secret"));
drop(app);
tokio::time::timeout(std::time::Duration::from_secs(2), relay)
.await
.unwrap()
.unwrap()
.unwrap();
if identity_result.is_err() {
let mut request = [0u8; 128];
match tokio::time::timeout(
std::time::Duration::from_secs(2),
upstream.read(&mut request),
)
.await
.unwrap()
{
Ok(n) => assert_eq!(n, 0, "failed grant must send no request bytes"),
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof),
}
}
fixture.assert_requested_keys(&[service_key, identity_key]);
}

#[tokio::test]
async fn l7_rest_relay_injects_token_grant_authorization_header() {
let (config, tunnel_engine, ctx, fixture) =
Expand Down
Loading
Loading