Files
OpenShell/e2e/rust/tests/provider_token_exchange.rs
Shiju ffb530671f fix(providers): prepare dynamic credentials across HTTP relay paths (#4152)
* fix(providers): bind multi-route grants to admitted endpoints

Carry gateway-derived endpoint owners through policy and credential delivery. Select grants only from owners that admit the current request, refresh provider snapshots per request, and reject superseded installations before forwarding.

Cover persistent routes, overlapping owners, denial, refresh, cache expiry and recovery with focused tests and a Podman regression.

Signed-off-by: Shiju <shiju@nvidia.com>

* refactor(providers): simplify admitted-endpoint grant selection

Share admitted endpoints between forwarding and credential authorization. Select grants from the live snapshot without rebuilding a locked map, reuse profile policy construction, and preserve freshness guards when grants are removed.

Signed-off-by: Shiju <shiju@nvidia.com>

* fix(providers): preserve trusted owners across policy approval

Sanitize authored owner metadata before composing effective policies, preserve derived owners through proposal approval, and normalize advisor endpoint comparisons. Account for gateway-only owner fields in SDK coverage and schema inventory.

Signed-off-by: Shiju <shiju@nvidia.com>

* refactor(providers): share dynamic credential key layout and require a snapshot for admission

Move the endpoint-bound credential key layout into openshell-core. DynamicCredentialKey encodes the key the gateway builds, and the supervisor reads its endpoint selector, credential identity, and revision-scoped form through the same module instead of parsing tab-separated strings in three places. Revision scoping becomes ProviderCredentialSnapshot::scoped_key.

inject_for_admitted_owners now takes the pinned snapshot directly. The L7 admission path never acquires a grant from the live credential map, which production populates only alongside a snapshot. L4 forwarding keeps the selector-only path through inject_if_needed. Both share one acquire-and-rewrite step.

Delete two relay tests whose behavior the Rego admission tests already prove. Add a test that grants for different headers are acquired only for admitting owners.

Signed-off-by: Shiju <shiju@nvidia.com>

* fix(providers): share credential preparation across HTTP relays

Apply endpoint-owned grants after policy and request middleware in REST,
GraphQL, JSON-RPC/MCP and inspected plaintext forwarding. Preserve live
provider and policy checks through guarded writes, reject missing owner
metadata, and reject profiles that cannot inject dynamic HTTP credentials.

Cover production stream dispatch, multiple endpoints, failure handling and
plaintext ownership with focused regressions. Document upgrade ordering.

Closes #3657

Signed-off-by: Shiju <shiju@nvidia.com>

* fix(providers): validate dynamic credential key encoding

Reject control characters at profile import and key construction. Propagate
invalid key errors through gateway environment resolution and reject malformed
key layouts before token acquisition while preserving supported legacy keys.

Consolidate duplicate relay success tests and share forward-proxy setup without
removing ownership, canonicalization, refresh or guarded-write assertions.
Document token-grant endpoint authority.

Signed-off-by: Shiju <shiju@nvidia.com>

---------

Signed-off-by: Shiju <shiju@nvidia.com>
2026-10-07 23:48:40 +00:00

1384 lines
55 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e-podman")]
use std::collections::HashMap;
use std::convert::Infallible;
use std::fs;
use std::io::Write as _;
use std::net::{Ipv4Addr, SocketAddr};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use futures_util::future::BoxFuture;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use openshell_e2e::harness::binary::openshell_cmd;
use openshell_e2e::harness::port::find_free_port;
use openshell_e2e::harness::sandbox::SandboxGuard;
use serde_json::json;
use tempfile::NamedTempFile;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::{TcpListener, TcpStream, UnixListener};
use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream, UnixListenerStream};
use tonic::body::Body as TonicBody;
use tonic::codegen::{Body, http};
use tonic::{Request, Response, Status};
const TRUST_DOMAIN: &str = "openshell-e2e.test";
const ISSUER: &str = "https://spiffe.openshell-e2e.test";
const KEY_ID: &str = "openshell-e2e-test-key";
const USER_SUBJECT_TOKEN: &str = "stored-user-token";
const INTERMEDIATE_TOKEN: &str = "intermediate-token";
const FINAL_ACCESS_TOKEN: &str = "final-access-token";
const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe";
const IDENTITY_AUDIENCE: &str = "identity-proxy";
const IDENTITY_JWT_AUDIENCE: &str = "https://identity.openshell-e2e.test";
const IDENTITY_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
// The harness gives this test binary one pair of SPIFFE listener endpoints.
// Serialize their ownership even when the Rust test runner uses many threads.
static SPIFFE_FIXTURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Default)]
struct GrantObservations {
service_issued: [AtomicUsize; 2],
identity_issued: [AtomicUsize; 2],
service_denied: AtomicUsize,
identity_denied: AtomicUsize,
target_requests: AtomicUsize,
target_rejected: AtomicUsize,
}
impl GrantObservations {
fn issued(&self) -> [(usize, usize); 2] {
std::array::from_fn(|index| {
(
self.service_issued[index].load(Ordering::SeqCst),
self.identity_issued[index].load(Ordering::SeqCst),
)
})
}
}
const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvCoZ0mVHpCHsF
zeeqw2caNIe/eb4BQUccFPhZfRnF7sCfyB84zTBmuwG2umRBdjFnVsfIIZRp2HcD
OESrRYYiE1RGfjBXImGVg2Wtza0HYhL1sLyX1eaEefylxoilmApAgWDh9p36h8J2
s5YHwyXPTttx4DpdWDnxju1iNmwoIB8uVE/5amWgbNvlETMBOcB1RxDHtnVy+xJz
jjjrzK4Qz9WsUTHAvngdi4Yyxvci+yKpjYTg5+UWxmAN6iW522TpLe32MDb5Ug1d
trBvvepWmdQ6CBwPhBHCt/sMoSJAYSO4RKeBnBjeLQBXFTxaOv5iTGIsRTX3K471
epHp3cT5AgMBAAECggEASQlRv/4nZN5SgsH/K8v7zb3kdHsmUly8AJYpaCGgauvr
uN/mUyueyga2uNl+MqhQBef6VWHZjO6y/gdw86v/Q2GgVQebQQhKAnpAp2w+Ceoc
siKMFqi8VkOWLU+xPbM6d97kH3TpRxt1g1T8wYFmWeF0BEiE4eUJzGaQW14M9BJ+
G0QxmP/zjX9cNpVeApKTjBWKiH4CXG3DuI3pJ93VOMpUlOsrdLXvKGTze0e01itr
MX/MHHTE+VXB4FB+/zKSA4c36egi676OSXrGC/GDmM8ntJ4CUGeD5uZsMSADiAUn
iccv5iGRWVMIKxUS5Q4k0jy8uWuK+QVP4Y6cQWYArwKBgQDhuSNORBNpIGRfsKGN
iJo/h+qinz6pEIpa3D3oVl7rpkyvgIyaTwfXvC1vfdS9V5VIel2gV2Cx0OrI8yrr
nQu1JuNV/rLmtvqX321fgBLRdoiqF3pAy1gbmdUz1elerAIYL578gXQ6jg1bbdic
kJpn0MsoDUJGwvJnXcgLqG7q3wKBgQDGhRIa4oJsj1vqICc8zt8YsCAcot3vjWLH
588X7JdBGOWJdWxfdmGXQRn5Zw9UhMQnYa3uyTBPeVcXopThlPotYeuFhLSU856T
IJzfpzCJzC4zIQayoyvJFrKe7N70iUQ986dewYy9oxQhHvFKd/qe4ylbzZJXpthX
eWEuuBSjJwKBgGkqXt6qLPj/1IQYwUw15tfOtW0LEKCoSi3HCzjidNsJ4hSqqdeD
Fr5WuDyHvcRxt+XKzTBVRYHTOnBhiw+3XasK8UQxpJyFh/+WY1jpTNs2hLnqslTZ
6LUDWSgLc+1d6qPmHAa9Ma/OWz7L0O4xGR9hUiXY95YMYe/y668yzGq1AoGBAJyU
Gsqfu7U6gYmxoKEine6QBFPx1dD7GF2KJdq93jMXGvyHZFoLOkAdtgnz0rCcI0bY
kWKUxwj4MMxQjNM8OPMQl75xBCmz2XA8Od9htDQLmqjzNKAzePabc3lMZTJFDlE6
29kuGf79IIRbLn/JECDAFT/2baW60Ep2T0OVJ5njAoGAfaCaQ4aVgjI027q7Y5qP
KfNSI8uuA8PLqmUY30I9KFWzN6VDLu00eKa90F4w3CeWRRQWXW1+007tTz3V1mNw
20A24Fi3HGQmXc7NyuLDODTJsWBICuOemCnRkvcxIlxb+ec7jp+XRmzDwKkzSnVN
pM2zFU8SeVkvHKlEuoHaP0s=
-----END PRIVATE KEY-----"; //notsecret
const TEST_RSA_MODULUS_HEX: &str = concat!(
"af0a86749951e9087b05cde7aac3671a3487bf79be0141471c14f8597d19c5eec09fc81f38cd3066bb01b6ba",
"644176316756c7c8219469d877033844ab4586221354467e30572261958365adcdad076212f5b0bc97d5e684",
"79fca5c688a5980a408160e1f69dfa87c276b39607c325cf4edb71e03a5d5839f18eed62366c28201f2e54",
"4ff96a65a06cdbe511330139c0754710c7b67572fb12738e38ebccae10cfd5ac5131c0be781d8b8632c6",
"f722fb22a98d84e0e7e516c6600dea25b9db64e92dedf63036f9520d5db6b06fbdea5699d43a081c0f84",
"11c2b7fb0ca122406123b844a7819c18de2d0057153c5a3afe624c622c4535f72b8ef57a91e9ddc4f9"
);
#[derive(Clone, PartialEq, prost::Message)]
struct JwtsvidRequest {
#[prost(string, repeated, tag = "1")]
audience: Vec<String>,
#[prost(string, tag = "2")]
spiffe_id: String,
}
#[derive(Clone, PartialEq, prost::Message)]
struct JwtsvidResponse {
#[prost(message, repeated, tag = "1")]
svids: Vec<Jwtsvid>,
}
#[derive(Clone, PartialEq, prost::Message)]
struct Jwtsvid {
#[prost(string, tag = "1")]
spiffe_id: String,
#[prost(string, tag = "2")]
svid: String,
#[prost(string, tag = "3")]
hint: String,
}
#[derive(Clone, PartialEq, prost::Message)]
struct JwtBundlesRequest {}
#[derive(Clone, PartialEq, prost::Message)]
struct JwtBundlesResponse {
#[prost(map = "string, bytes", tag = "1")]
bundles: HashMap<String, Vec<u8>>,
}
#[derive(Clone)]
struct SpiffeWorkloadApi {
subject: Arc<str>,
jwks: Arc<Vec<u8>>,
encoding_key: Arc<EncodingKey>,
}
impl SpiffeWorkloadApi {
fn jwt_svid(&self, audience: &[String]) -> Result<String, Status> {
let now = unix_timestamp();
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(KEY_ID.to_string());
let claims = json!({
"iss": ISSUER,
"sub": self.subject.as_ref(),
"aud": audience,
"iat": now,
"exp": now + 3600,
});
jsonwebtoken::encode(&header, &claims, &self.encoding_key)
.map_err(|err| Status::internal(format!("sign JWT-SVID: {err}")))
}
}
#[derive(Clone)]
struct SpiffeWorkloadApiServer {
inner: Arc<SpiffeWorkloadApi>,
}
impl SpiffeWorkloadApiServer {
fn new(inner: SpiffeWorkloadApi) -> Self {
Self {
inner: Arc::new(inner),
}
}
}
impl<B> tower::Service<http::Request<B>> for SpiffeWorkloadApiServer
where
B: Body + Send + 'static,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>> + Send + 'static,
{
type Response = http::Response<TonicBody>;
type Error = Infallible;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/SpiffeWorkloadAPI/FetchJWTSVID" => {
#[derive(Clone)]
struct FetchJwtsvidSvc(Arc<SpiffeWorkloadApi>);
impl tonic::server::UnaryService<JwtsvidRequest> for FetchJwtsvidSvc {
type Response = JwtsvidResponse;
type Future = BoxFuture<'static, Result<Response<Self::Response>, Status>>;
fn call(&mut self, request: Request<JwtsvidRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
Box::pin(async move {
let request = request.into_inner();
let svid = inner.jwt_svid(&request.audience)?;
Ok(Response::new(JwtsvidResponse {
svids: vec![Jwtsvid {
spiffe_id: inner.subject.to_string(),
svid,
hint: String::new(),
}],
}))
})
}
}
let inner = Arc::clone(&self.inner);
Box::pin(async move {
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec);
Ok(grpc.unary(FetchJwtsvidSvc(inner), req).await)
})
}
"/SpiffeWorkloadAPI/FetchJWTBundles" => {
#[derive(Clone)]
struct FetchJwtBundlesSvc(Arc<SpiffeWorkloadApi>);
impl tonic::server::ServerStreamingService<JwtBundlesRequest> for FetchJwtBundlesSvc {
type Response = JwtBundlesResponse;
type ResponseStream = ReceiverStream<Result<JwtBundlesResponse, Status>>;
type Future =
BoxFuture<'static, Result<Response<Self::ResponseStream>, Status>>;
fn call(&mut self, _request: Request<JwtBundlesRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
Box::pin(async move {
let mut bundles = HashMap::new();
bundles.insert(TRUST_DOMAIN.to_string(), inner.jwks.as_ref().clone());
let (tx, rx) = tokio::sync::mpsc::channel(1);
tx.send(Ok(JwtBundlesResponse { bundles }))
.await
.map_err(|err| Status::internal(format!("send bundle: {err}")))?;
Ok(Response::new(ReceiverStream::new(rx)))
})
}
}
let inner = Arc::clone(&self.inner);
Box::pin(async move {
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec);
Ok(grpc.server_streaming(FetchJwtBundlesSvc(inner), req).await)
})
}
_ => Box::pin(async move {
let mut response = http::Response::new(TonicBody::empty());
response.headers_mut().insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
response.headers_mut().insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
}),
}
}
}
impl tonic::server::NamedService for SpiffeWorkloadApiServer {
const NAME: &'static str = "SpiffeWorkloadAPI";
}
struct FixtureHandle {
task: tokio::task::JoinHandle<()>,
}
impl FixtureHandle {
async fn shutdown(mut self) {
self.task.abort();
let _ = (&mut self.task).await;
}
}
impl Drop for FixtureHandle {
fn drop(&mut self) {
self.task.abort();
}
}
fn unix_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_secs()
.try_into()
.expect("timestamp should fit i64")
}
fn jwks() -> Vec<u8> {
let modulus = hex::decode(TEST_RSA_MODULUS_HEX).expect("valid test RSA modulus hex");
let n = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(modulus);
let e = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x01, 0x00, 0x01]);
serde_json::to_vec(&json!({
"keys": [{
"kty": "RSA",
"kid": KEY_ID,
"use": "sig",
"alg": "RS256",
"n": n,
"e": e,
}]
}))
.expect("JWKS should serialize")
}
async fn start_spiffe_workload_api(path: &Path, subject: &str) -> FixtureHandle {
let api = SpiffeWorkloadApi {
subject: Arc::<str>::from(subject),
jwks: Arc::new(jwks()),
encoding_key: Arc::new(
EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY.as_bytes())
.expect("test RSA key should parse"),
),
};
let endpoint = path.to_string_lossy();
if endpoint.starts_with("tcp:") {
let listen = std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN")
.expect("OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN must be set for TCP SPIFFE fixture");
let listener = TcpListener::bind(&listen)
.await
.expect("bind TCP SPIFFE Workload API fixture");
let incoming = TcpListenerStream::new(listener);
let task = tokio::spawn(async move {
let result = tonic::transport::Server::builder()
.add_service(SpiffeWorkloadApiServer::new(api))
.serve_with_incoming(incoming)
.await;
if let Err(err) = result {
eprintln!("SPIFFE Workload API fixture failed: {err}");
}
});
return FixtureHandle { task };
}
let _ = fs::remove_file(path);
let listener = UnixListener::bind(path).expect("bind SPIFFE Workload API socket");
let mut permissions = fs::metadata(path)
.expect("stat SPIFFE Workload API socket")
.permissions();
permissions.set_mode(0o777);
fs::set_permissions(path, permissions).expect("chmod SPIFFE Workload API socket");
let incoming = UnixListenerStream::new(listener);
let task = tokio::spawn(async move {
let result = tonic::transport::Server::builder()
.add_service(SpiffeWorkloadApiServer::new(api))
.serve_with_incoming(incoming)
.await;
if let Err(err) = result {
eprintln!("SPIFFE Workload API fixture failed: {err}");
}
});
FixtureHandle { task }
}
// Forms and JWT headers can span TCP reads. Read the bounded, Content-Length
// framed request completely so packet boundaries cannot change fixture results.
async fn read_http_request(stream: &mut TcpStream) -> Option<String> {
tokio::time::timeout(Duration::from_secs(5), async {
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let length = stream.read(&mut buffer).await.ok()?;
if length == 0 || request.len() + length > 32 * 1024 {
return None;
}
request.extend_from_slice(&buffer[..length]);
if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
let headers = std::str::from_utf8(&request[..end]).ok()?;
let content_length = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.map_or(Some(0), |(_, value)| value.trim().parse::<usize>().ok())?;
if request.len() >= end.checked_add(4)?.checked_add(content_length)? {
return String::from_utf8(request).ok();
}
}
}
})
.await
.ok()
.flatten()
}
fn verifies_jwt_svid(token: &str, instance: &str) -> bool {
let keys: jsonwebtoken::jwk::JwkSet =
serde_json::from_slice(&jwks()).expect("fixture JWKS should parse");
let key = DecodingKey::from_jwk(keys.find(KEY_ID).expect("fixture signing key exists"))
.expect("fixture verification key should parse");
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[ISSUER]);
validation.set_audience(&[format!("{IDENTITY_JWT_AUDIENCE}/{instance}")]);
validation.sub = Some(format!("spiffe://{TRUST_DOMAIN}/openshell/sandbox/e2e"));
validation.set_required_spec_claims(&["exp", "iat", "iss", "sub", "aud"]);
jsonwebtoken::decode::<serde_json::Value>(token, &key, &validation)
.is_ok_and(|verified| verified.header.kid.as_deref() == Some(KEY_ID))
}
fn token_form(request: &str) -> HashMap<String, String> {
let (_, body) = request.split_once("\r\n\r\n").unwrap_or_default();
url::form_urlencoded::parse(body.as_bytes())
.into_owned()
.collect()
}
async fn write_token_response(stream: &mut TcpStream, access_token: Option<&str>, ttl: u64) {
let (status, body) = if let Some(access_token) = access_token {
(
"HTTP/1.1 200 OK",
json!({"access_token": access_token, "token_type": "Bearer", "expires_in": ttl})
.to_string(),
)
} else {
(
"HTTP/1.1 400 Bad Request",
json!({"error": "invalid_grant"}).to_string(),
)
};
let response = format!(
"{status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
}
async fn start_gateway_token_endpoint(
port: u16,
observations: Arc<GrantObservations>,
) -> FixtureHandle {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
.await
.expect("bind gateway token endpoint");
let task = tokio::spawn(async move {
loop {
let Ok((mut stream, _peer)) = listener.accept().await else {
break;
};
let observations = Arc::clone(&observations);
tokio::spawn(async move {
let Some(request) = read_http_request(&mut stream).await else {
return;
};
let form = token_form(&request);
let field = |name: &str| form.get(name).map_or("", String::as_str);
let valid = request.starts_with("POST /token ")
&& field("grant_type") == "urn:ietf:params:oauth:grant-type:token-exchange"
&& field("client_assertion_type") == CLIENT_ASSERTION_TYPE
&& field("subject_token_type") == TOKEN_TYPE_ACCESS_TOKEN
&& field("requested_token_type") == TOKEN_TYPE_ACCESS_TOKEN
&& !field("client_assertion").is_empty();
let mut access_token = None;
for (index, instance) in ["a", "b"].into_iter().enumerate() {
if valid
&& field("subject_token") == format!("{USER_SUBJECT_TOKEN}-{instance}")
&& field("audience")
== format!("spiffe://{TRUST_DOMAIN}/openshell/sandbox/e2e")
&& field("scope").is_empty()
{
access_token = Some(format!("{INTERMEDIATE_TOKEN}-{instance}"));
} else if valid
&& field("subject_token") == format!("{INTERMEDIATE_TOKEN}-{instance}")
&& field("scope") == "service.read"
{
if field("audience") == format!("service-{instance}") {
observations.service_issued[index].fetch_add(1, Ordering::SeqCst);
access_token = Some(format!("{FINAL_ACCESS_TOKEN}-{instance}"));
} else if field("audience") == format!("denied-service-{instance}") {
observations.service_denied.fetch_add(1, Ordering::SeqCst);
}
}
}
write_token_response(&mut stream, access_token.as_deref(), 600).await;
});
}
});
FixtureHandle { task }
}
// This issuer returns the actual Workload API JWT-SVID after checking its
// signature and audience. No new production grant type or sandbox-visible
// credential is needed to exercise custom-header injection.
async fn start_identity_token_endpoint(
port: u16,
observations: Arc<GrantObservations>,
) -> FixtureHandle {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
.await
.expect("bind identity token endpoint");
let task = tokio::spawn(async move {
loop {
let Ok((mut stream, _peer)) = listener.accept().await else {
break;
};
let observations = Arc::clone(&observations);
tokio::spawn(async move {
let Some(request) = read_http_request(&mut stream).await else {
return;
};
let form = token_form(&request);
let field = |name: &str| form.get(name).map_or("", String::as_str);
let mut access_token = None;
for (index, instance) in ["a", "b"].into_iter().enumerate() {
let valid = request.starts_with("POST /identity-token ")
&& field("grant_type") == "client_credentials"
&& field("client_assertion_type") == IDENTITY_ASSERTION_TYPE
&& field("scope") == "identity.read"
&& verifies_jwt_svid(field("client_assertion"), instance);
if valid && field("audience") == format!("{IDENTITY_AUDIENCE}-{instance}") {
observations.identity_issued[index].fetch_add(1, Ordering::SeqCst);
access_token = Some(field("client_assertion"));
} else if valid && field("audience") == format!("denied-identity-{instance}") {
observations.identity_denied.fetch_add(1, Ordering::SeqCst);
}
}
write_token_response(&mut stream, access_token, 900).await;
});
}
});
FixtureHandle { task }
}
async fn start_protected_target(port: u16, observations: Arc<GrantObservations>) -> FixtureHandle {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)))
.await
.expect("bind protected target");
let task = tokio::spawn(async move {
loop {
let Ok((mut stream, _peer)) = listener.accept().await else {
break;
};
let observations = Arc::clone(&observations);
tokio::spawn(async move {
// Count any request bytes, including an incomplete header. A
// failed grant may open a TCP stream but must send no request.
let mut byte = [0_u8; 1];
if !matches!(
tokio::time::timeout(Duration::from_secs(5), stream.peek(&mut byte)).await,
Ok(Ok(1))
) {
return;
}
observations.target_requests.fetch_add(1, Ordering::SeqCst);
let Some(request) = read_http_request(&mut stream).await else {
return;
};
let headers = request.split("\r\n\r\n").next().unwrap_or_default();
let header_values = |name: &str| {
headers
.lines()
.filter_map(|line| line.split_once(':'))
.filter(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.trim())
.collect::<Vec<_>>()
};
let bearer = header_values("authorization");
let identity = header_values("x-workload-jwt");
let ok = ["a", "b"].into_iter().any(|instance| {
request.starts_with(&format!("GET /resource/{instance} "))
&& bearer.len() == 1
&& bearer[0] == format!("Bearer {FINAL_ACCESS_TOKEN}-{instance}")
&& identity.len() == 1
&& verifies_jwt_svid(identity[0], instance)
});
let (status, body) = if ok {
("HTTP/1.1 200 OK", "independent-grants-ok")
} else {
// Keep rejection evidence even if the workload retries and
// a later request happens to receive the correct credentials.
observations.target_rejected.fetch_add(1, Ordering::SeqCst);
(
"HTTP/1.1 401 Unauthorized",
"credential-verification-failed",
)
};
let response = format!(
"{status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
});
FixtureHandle { task }
}
async fn run_cli(args: &[&str]) -> Result<(), String> {
let output = openshell_cmd()
.args(args)
.output()
.await
.map_err(|_| "could not spawn openshell CLI".to_string())?;
if output.status.success() {
Ok(())
} else {
// Provider creation arguments contain the stored subject token.
// Keep command arguments and raw diagnostics out of test failures.
Err(format!(
"openshell command failed; exit={:?}",
output.status.code()
))
}
}
async fn run_cli_ignore_error(args: &[&str]) {
let _ = openshell_cmd().args(args).output().await;
}
fn write_profile(
profile_type: &str,
token_port: u16,
identity_port: u16,
target_port: u16,
instance: &str,
) -> NamedTempFile {
let token_endpoint = format!("http://127.0.0.1:{token_port}/token");
let mut file = tempfile::Builder::new()
.suffix(".yaml")
.tempfile()
.expect("create provider profile temp file");
let profile = format!(
r"id: {profile_type}
display_name: Podman token exchange e2e
description: Independent bearer and JWT-SVID grants for one request
category: other
credentials:
- name: subject_token
description: Stored user subject token
required: true
- name: access_token
description: Access token obtained through token exchange
required: false
auth_style: bearer
header_name: Authorization
token_grant:
grant_type: token_exchange
token_endpoint: {token_endpoint}
audience: service-{instance}
scopes: [service.read]
jwt_svid_audience: {token_endpoint}
client_assertion_type: {CLIENT_ASSERTION_TYPE}
requested_token_type: {TOKEN_TYPE_ACCESS_TOKEN}
cache_ttl_seconds: 300
audience_overrides:
- path: /deny-service/{instance}
audience: denied-service-{instance}
subject_token:
source: provider_credential
credential: subject_token
subject_token_type: {TOKEN_TYPE_ACCESS_TOKEN}
- name: workload_identity
description: Signed workload identity for the identity proxy
required: false
auth_style: header
header_name: X-Workload-Jwt
token_grant:
grant_type: client_credentials
token_endpoint: http://127.0.0.1:{identity_port}/identity-token
audience: {IDENTITY_AUDIENCE}-{instance}
scopes: [identity.read]
jwt_svid_audience: {IDENTITY_JWT_AUDIENCE}/{instance}
client_assertion_type: {IDENTITY_ASSERTION_TYPE}
cache_ttl_seconds: 600
audience_overrides:
- path: /deny-identity/{instance}
audience: denied-identity-{instance}
endpoints:
- host: host.openshell.internal
port: {target_port}
path: /**
protocol: rest
access: read-write
enforcement: enforce
allowed_ips:
- 10.0.0.0/8
- 169.254.0.0/16
- 172.0.0.0/8
- 192.168.0.0/16
binaries:
- /**
"
);
file.write_all(profile.as_bytes())
.expect("write provider profile");
file.flush().expect("flush provider profile");
file
}
/// Writes a profile with one bearer token-exchange grant bound to `/{route}/**`.
/// Each route carries its own audience so the token endpoint can tell which
/// endpoint's grant the supervisor selected.
fn write_route_profile(
profile_type: &str,
token_port: u16,
target_port: u16,
route: &str,
) -> NamedTempFile {
let token_endpoint = format!("http://127.0.0.1:{token_port}/token");
let mut file = tempfile::Builder::new()
.suffix(".yaml")
.tempfile()
.expect("create provider profile temp file");
let profile = format!(
r"id: {profile_type}
display_name: Podman token exchange route e2e
description: Route-bound bearer grant
category: other
credentials:
- name: subject_token
description: Stored user subject token
required: true
- name: access_token
description: Access token obtained through token exchange
required: false
auth_style: bearer
header_name: Authorization
token_grant:
grant_type: token_exchange
token_endpoint: {token_endpoint}
audience: audience-{route}
jwt_svid_audience: {token_endpoint}
client_assertion_type: {CLIENT_ASSERTION_TYPE}
requested_token_type: {TOKEN_TYPE_ACCESS_TOKEN}
cache_ttl_seconds: 300
subject_token:
source: provider_credential
credential: subject_token
subject_token_type: {TOKEN_TYPE_ACCESS_TOKEN}
endpoints:
- host: host.openshell.internal
port: {target_port}
path: /{route}/**
protocol: rest
access: read-write
enforcement: enforce
allowed_ips:
- 10.0.0.0/8
- 169.254.0.0/16
- 172.0.0.0/8
- 192.168.0.0/16
binaries:
- /**
"
);
file.write_all(profile.as_bytes())
.expect("write provider profile");
file.flush().expect("flush provider profile");
file
}
fn sandbox_script() -> String {
r"set -eu
echo token-server-ready
while true; do sleep 60; done
"
.to_string()
}
async fn sandbox_exec_http(
sandbox_name: &str,
target_port: u16,
path: &str,
expect_denied: bool,
) -> Result<String, String> {
let url = format!("http://host.openshell.internal:{target_port}{path}");
let expect_denied_python = if expect_denied { "True" } else { "False" };
let script = format!(
r#"import base64, json, os, re, urllib.error, urllib.request
def contains_credential(value):
if any(marker in value for marker in (
"stored-user-token", "intermediate-token", "final-access-token", "openshell:resolve:"
)):
return True
for token in re.findall(r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", value):
try:
payload = token.split(".")[1]
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
if claims.get("iss") == "{ISSUER}":
return True
except (ValueError, UnicodeError):
pass
return False
if any(contains_credential(value) for value in os.environ.values()):
raise RuntimeError("provider credential material reached the workload environment")
request = urllib.request.Request({url:?}, headers={{
"Authorization": "Bearer agent-supplied",
"X-Workload-Jwt": "agent-supplied",
}})
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = response.read().decode()
if contains_credential(body):
raise RuntimeError("provider credential material reached the workload response")
if {expect_denied_python}:
raise RuntimeError("failed grant unexpectedly reached the target")
print(body)
except urllib.error.HTTPError as error:
body = error.read().decode()
if contains_credential(body):
raise RuntimeError("provider credential material reached the workload error")
if not {expect_denied_python} or error.code != 502:
raise RuntimeError("unexpected HTTP response") from None
print("grant-denied")
"#
);
let mut last_status = None;
for _ in 0..20 {
let output = openshell_cmd()
.args([
"sandbox",
"exec",
"--name",
sandbox_name,
"--no-tty",
"--",
"python3",
"-c",
&script,
])
.output()
.await
.map_err(|_| "could not spawn sandbox HTTP probe".to_string())?;
if output.status.success() {
// The child emits fixed markers only. Never return raw stderr:
// runtime failures can include request or credential diagnostics.
let stdout = String::from_utf8_lossy(&output.stdout);
let expected = if expect_denied {
"grant-denied"
} else {
"independent-grants-ok"
};
if stdout.trim() == expected {
return Ok(expected.to_string());
}
return Err("sandbox probe returned unexpected output".to_string());
}
last_status = output.status.code();
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(format!("sandbox HTTP probe failed; exit={last_status:?}"))
}
async fn create_provider_instance(
token_port: u16,
identity_port: u16,
target_port: u16,
instance: &str,
) -> String {
let name = format!("podman-grants-{instance}-{}", std::process::id());
run_cli_ignore_error(&["provider", "delete", &name, "--yes"]).await;
run_cli_ignore_error(&["profile", "delete", &name, "--yes"]).await;
let profile = write_profile(&name, token_port, identity_port, target_port, instance);
let profile_path = profile
.path()
.to_str()
.expect("profile path should be UTF-8");
run_cli(&["profile", "import", "-f", profile_path])
.await
.expect("import independent-grant profile");
run_cli(&[
"provider",
"create",
"--name",
&name,
"--type",
&name,
"--credential",
&format!("subject_token={USER_SUBJECT_TOKEN}-{instance}"),
])
.await
.expect("create provider instance");
name
}
#[tokio::test]
async fn podman_provider_token_exchange_injects_independent_grants_across_sandboxes() {
let _fixture_guard = SPIFFE_FIXTURE_LOCK.lock().await;
let gateway_socket = PathBuf::from(
std::env::var("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET")
.expect("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET must be set by e2e-podman.sh"),
);
let provider_socket = PathBuf::from(
std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET")
.expect("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET must be set by e2e-podman.sh"),
);
let token_port = find_free_port();
let identity_port = find_free_port();
let target_port = find_free_port();
let gateway_subject = format!("spiffe://{TRUST_DOMAIN}/openshell/gateway");
// The mock shares a workload subject. Distinct providers and JWT audiences
// test cross-sandbox credential isolation, not production SPIRE attestation.
let supervisor_subject = format!("spiffe://{TRUST_DOMAIN}/openshell/sandbox/e2e");
let observations = Arc::new(GrantObservations::default());
let _gateway_spiffe = start_spiffe_workload_api(&gateway_socket, &gateway_subject).await;
let _provider_spiffe = start_spiffe_workload_api(&provider_socket, &supervisor_subject).await;
let _gateway_token = start_gateway_token_endpoint(token_port, Arc::clone(&observations)).await;
let _identity_token =
start_identity_token_endpoint(identity_port, Arc::clone(&observations)).await;
let _target = start_protected_target(target_port, Arc::clone(&observations)).await;
let mut provider_names = Vec::new();
for instance in ["a", "b"] {
provider_names
.push(create_provider_instance(token_port, identity_port, target_port, instance).await);
}
let script = sandbox_script();
let mut sandbox_a = SandboxGuard::create_keep_with_args(
&["--provider", &provider_names[0]],
&["sh", "-lc", &script],
"token-server-ready",
)
.await
.expect("create sandbox A");
let mut sandbox_b = SandboxGuard::create_keep_with_args(
&["--provider", &provider_names[1]],
&["sh", "-lc", &script],
"token-server-ready",
)
.await
.expect("create sandbox B");
// Both supervisors request the same host/port concurrently. The target
// verifies each path's distinct bearer and signed JWT audience together.
let initial = tokio::join!(
sandbox_exec_http(&sandbox_a.name, target_port, "/resource/a", false),
sandbox_exec_http(&sandbox_b.name, target_port, "/resource/b", false),
);
let before_cache = observations.issued();
let cached = tokio::join!(
sandbox_exec_http(&sandbox_a.name, target_port, "/resource/a", false),
sandbox_exec_http(&sandbox_b.name, target_port, "/resource/b", false),
);
let after_cache = observations.issued();
let target_requests = observations.target_requests.load(Ordering::SeqCst);
// An audience override forces acquisition of only the failing credential;
// the other credential remains cached from the successful request.
let denied = tokio::join!(
sandbox_exec_http(&sandbox_a.name, target_port, "/deny-service/a", true),
sandbox_exec_http(&sandbox_b.name, target_port, "/deny-identity/b", true),
);
sandbox_a.cleanup().await;
sandbox_b.cleanup().await;
for name in &provider_names {
run_cli_ignore_error(&["provider", "delete", name, "--yes"]).await;
run_cli_ignore_error(&["profile", "delete", name, "--yes"]).await;
}
// Release the shared SPIFFE endpoints before the next test binds them.
_target.shutdown().await;
_identity_token.shutdown().await;
_gateway_token.shutdown().await;
_provider_spiffe.shutdown().await;
_gateway_spiffe.shutdown().await;
for outcome in [initial.0, initial.1, cached.0, cached.1] {
assert_eq!(
outcome.expect("independent grant request should succeed"),
"independent-grants-ok"
);
}
assert_eq!(
observations.target_rejected.load(Ordering::SeqCst),
0,
"no request may present mismatched or untrusted credentials, even before a retry"
);
assert!(
before_cache
.iter()
.all(|(service, identity)| *service > 0 && *identity > 0),
"each provider instance must acquire both credentials"
);
assert_eq!(
after_cache, before_cache,
"repeat requests should use both caches"
);
for outcome in [denied.0, denied.1] {
assert_eq!(
outcome.expect("failed grant should return 502"),
"grant-denied"
);
}
assert!(observations.service_denied.load(Ordering::SeqCst) > 0);
assert!(observations.identity_denied.load(Ordering::SeqCst) > 0);
assert_eq!(
observations.target_requests.load(Ordering::SeqCst),
target_requests,
"neither failed grant may forward any request bytes"
);
}
// Read one whole fixture request without consuming the following keepalive
// request. OAuth forms can arrive separately from their headers.
async fn read_route_fixture_request(
stream: &mut TcpStream,
observed_bytes: Option<&AtomicUsize>,
) -> Result<Option<String>, String> {
tokio::time::timeout(Duration::from_secs(30), async {
let mut bytes = Vec::new();
while !bytes.ends_with(b"\r\n\r\n") {
let mut byte = [0];
if stream
.read(&mut byte)
.await
.map_err(|err| err.to_string())?
== 0
{
return if bytes.is_empty() {
Ok(None)
} else {
Err("partial fixture header".into())
};
}
bytes.push(byte[0]);
if let Some(observed) = observed_bytes {
observed.fetch_add(1, Ordering::Relaxed);
}
if bytes.len() > 16 * 1024 {
return Err("fixture header exceeds limit".into());
}
}
let header = std::str::from_utf8(&bytes).map_err(|err| err.to_string())?;
let length = header
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.map(|(_, value)| value.trim().parse::<usize>())
.transpose()
.map_err(|err| err.to_string())?
.unwrap_or(0);
if length > 64 * 1024 {
return Err("fixture body exceeds limit".into());
}
let header_len = bytes.len();
bytes.resize(header_len + length, 0);
stream
.read_exact(&mut bytes[header_len..])
.await
.map_err(|err| err.to_string())?;
if let Some(observed) = observed_bytes {
observed.fetch_add(length, Ordering::Relaxed);
}
String::from_utf8(bytes)
.map(Some)
.map_err(|err| err.to_string())
})
.await
.map_err(|_| "fixture request timed out".to_owned())?
}
#[derive(Default)]
struct RouteGrantState {
fail_next_b: AtomicBool,
supervisor_requests: Mutex<Vec<String>>,
}
async fn start_route_token_endpoint(port: u16, state: Arc<RouteGrantState>) -> FixtureHandle {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
.await
.expect("bind route token endpoint");
let task = tokio::spawn(async move {
let mut requests = tokio::task::JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((mut stream, _)) = accepted else { break; };
let state = state.clone();
requests.spawn(async move {
let Ok(Some(request)) = read_route_fixture_request(&mut stream, None).await else { return; };
let form = request.split_once("\r\n\r\n").map_or("", |(_, body)| body);
let fields = form.split('&').filter_map(|part| part.split_once('=')).collect::<HashMap<_, _>>();
let subject = fields.get("subject_token").copied().unwrap_or_default();
let route = match subject {
"stored-a" | "intermediate-a" => "a",
"stored-b" | "intermediate-b" => "b",
_ => "",
};
let supervisor = subject.starts_with("intermediate-");
let valid = !route.is_empty() && request.starts_with("POST /token ")
&& fields.get("client_assertion").is_some_and(|value| !value.is_empty())
// Gateway exchange targets the supervisor's SPIFFE
// subject; final exchange targets the route audience.
&& fields.get("audience").is_some_and(|value| !value.is_empty()
&& (!supervisor || *value == format!("audience-{route}")));
let fail = if valid && supervisor {
state.supervisor_requests.lock().expect("route grant requests lock").push(route.to_owned());
route == "b" && state.fail_next_b.swap(false, Ordering::SeqCst)
} else { false };
let (status, body) = if fail {
("HTTP/1.1 503 Service Unavailable", json!({"error": "temporary_failure"}).to_string())
} else if valid {
let stage = if supervisor { "final" } else { "intermediate" };
("HTTP/1.1 200 OK", json!({
"access_token": format!("{stage}-{route}"), "token_type": "Bearer", "expires_in": 300
}).to_string())
} else {
("HTTP/1.1 400 Bad Request", json!({"error": "unexpected_route_exchange"}).to_string())
};
let response = format!("{status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = tokio::time::timeout(Duration::from_secs(5), stream.write_all(response.as_bytes())).await;
});
}
_ = requests.join_next(), if !requests.is_empty() => {}
}
}
});
FixtureHandle { task }
}
struct RouteTargetRequest {
connection: usize,
path: String,
authorization: Vec<String>,
bytes: usize,
}
struct RouteTargetHandle {
stop: Option<tokio::sync::oneshot::Sender<()>>,
task: tokio::task::JoinHandle<Result<(), String>>,
}
impl RouteTargetHandle {
async fn drain(mut self) -> Result<(), String> {
if let Some(stop) = self.stop.take() {
// A closed receiver means the task already stopped; joining still
// reports its error rather than hiding it behind the stop signal.
let _ = stop.send(());
}
tokio::time::timeout(Duration::from_secs(35), &mut self.task)
.await
.map_err(|_| "route target did not drain accepted connections".to_owned())?
.map_err(|err| format!("route target task failed: {err}"))?
}
}
impl Drop for RouteTargetHandle {
fn drop(&mut self) {
// Failure and timeout cleanup must also cancel the owned JoinSet.
self.task.abort();
}
}
async fn start_route_target(
port: u16,
observed: Arc<Mutex<Vec<RouteTargetRequest>>>,
observed_bytes: Arc<AtomicUsize>,
) -> RouteTargetHandle {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)))
.await
.expect("bind route target");
let (stop, mut stopped) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let next_connection = AtomicUsize::new(1);
let mut connections = tokio::task::JoinSet::new();
let mut completed = Vec::new();
let mut accept_error = None;
loop {
tokio::select! {
// Once the workload has closed, accept queued connections
// before stopping so their buffered bytes are also observed.
biased;
accepted = listener.accept() => {
let (mut stream, _) = match accepted {
Ok(accepted) => accepted,
Err(err) => {
accept_error = Some(format!("route target accept failed: {err}"));
break;
}
};
let connection = next_connection.fetch_add(1, Ordering::Relaxed);
let observed = observed.clone();
let observed_bytes = observed_bytes.clone();
connections.spawn(async move {
loop {
let Some(request) = read_route_fixture_request(&mut stream, Some(&observed_bytes)).await? else { return Ok::<(), String>(()); };
let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or_default().to_owned();
let authorization = request.lines().filter_map(|line| line.split_once(':'))
.filter(|(name, _)| name.eq_ignore_ascii_case("authorization"))
.map(|(_, value)| value.trim().to_owned()).collect::<Vec<_>>();
let expected = if path.starts_with("/a/") { "Bearer final-a" } else { "Bearer final-b" };
let valid = authorization.len() == 1 && authorization[0] == expected;
observed.lock().expect("route target observations lock").push(RouteTargetRequest { connection, path, authorization, bytes: request.len() });
let status = if valid { "HTTP/1.1 200 OK" } else { "HTTP/1.1 401 Unauthorized" };
let response = format!("{status}\r\nContent-Length: 0\r\n\r\n");
tokio::time::timeout(Duration::from_secs(5), stream.write_all(response.as_bytes())).await
.map_err(|_| "route target response write timed out".to_owned())?
.map_err(|err| format!("route target response write failed: {err}"))?;
}
});
}
_ = &mut stopped => break,
Some(result) = connections.join_next(), if !connections.is_empty() => completed.push(result),
}
}
drop(listener);
// Only EOF proves that the byte counter includes everything sent on
// each accepted connection. Do not abort pending readers on success.
while let Some(result) = connections.join_next().await {
completed.push(result);
}
if let Some(error) = accept_error {
return Err(error);
}
for result in completed {
result.map_err(|err| format!("route target reader task failed: {err}"))??;
}
Ok(())
});
RouteTargetHandle {
stop: Some(stop),
task,
}
}
#[tokio::test]
async fn podman_provider_routes_recover_and_select_a_b_a_on_one_connection() {
let _fixture_guard = SPIFFE_FIXTURE_LOCK.lock().await;
let gateway_socket = PathBuf::from(
std::env::var("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET")
.expect("gateway SPIFFE fixture endpoint"),
);
let provider_socket = PathBuf::from(
std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET")
.expect("provider SPIFFE fixture endpoint"),
);
// A remote Podman VM can forward this fixed loopback port to the test
// process. Keep the issuer URL on loopback so the fixture obeys the
// production token endpoint transport policy on both sides of the tunnel.
let token_port = std::env::var("OPENSHELL_E2E_TOKEN_PORT").map_or_else(
|_| find_free_port(),
|port| {
let port = port
.parse::<u16>()
.expect("OPENSHELL_E2E_TOKEN_PORT must be a valid TCP port");
assert_ne!(port, 0, "OPENSHELL_E2E_TOKEN_PORT must be nonzero");
port
},
);
let target_port = find_free_port();
let grants = Arc::new(RouteGrantState::default());
let observed = Arc::new(Mutex::new(Vec::new()));
let observed_bytes = Arc::new(AtomicUsize::new(0));
let _gateway_spiffe = start_spiffe_workload_api(
&gateway_socket,
&format!("spiffe://{TRUST_DOMAIN}/openshell/gateway"),
)
.await;
let _provider_spiffe = start_spiffe_workload_api(
&provider_socket,
&format!("spiffe://{TRUST_DOMAIN}/openshell/sandbox/e2e"),
)
.await;
let _token_endpoint = start_route_token_endpoint(token_port, grants.clone()).await;
let _target = start_route_target(target_port, observed.clone(), observed_bytes.clone()).await;
let names = ["a", "b"].map(|route| format!("podman-route-{route}-{}", std::process::id()));
for name in &names {
run_cli_ignore_error(&["provider", "delete", name, "--yes"]).await;
run_cli_ignore_error(&["profile", "delete", name, "--yes"]).await;
}
let result: Result<String, String> = async {
for (route, name) in ["a", "b"].iter().zip(&names) {
let profile = write_route_profile(name, token_port, target_port, route);
run_cli(&[
"profile",
"import",
"-f",
profile.path().to_str().expect("UTF-8 profile path"),
])
.await?;
run_cli(&[
"provider",
"create",
"--name",
name,
"--type",
name,
"--credential",
&format!("subject_token=stored-{route}"),
])
.await?;
}
let mut sandbox = SandboxGuard::create_keep_with_args(
&["--provider", &names[0], "--provider", &names[1]],
&["sh", "-lc", &sandbox_script()],
"token-server-ready",
)
.await?;
grants.fail_next_b.store(true, Ordering::SeqCst);
// The supervisor mediates direct workload connections as CONNECT.
// HTTPConnection keeps one client socket for the successful A/B/A run.
let script = format!(
r#"import http.client
host = 'host.openshell.internal'
port = {target_port}
first = http.client.HTTPConnection(host, port, timeout=10)
first.request('GET', '/b/failure', headers={{'Authorization': 'Bearer stale-value'}})
failed = first.getresponse()
failed.read()
assert failed.status == 502, 'the controlled grant failure must return 502'
assert failed.will_close, 'the failed tunnel must close'
first.close()
recovered = http.client.HTTPConnection(host, port, timeout=10)
connection = None
for path in ['/a/first', '/b/second', '/a/third']:
recovered.request('GET', path, headers={{'Authorization': 'Bearer stale-value'}})
if connection is None:
connection = recovered.sock
assert recovered.sock is connection, 'client connection was replaced'
response = recovered.getresponse()
response.read()
assert response.status == 200, 'upstream rejected the selected credential'
assert recovered.sock is connection and connection.fileno() >= 0, 'keepalive connection closed'
recovered.close()
print('route-recovery-a-b-a-ok')
"#
);
let output = tokio::time::timeout(
Duration::from_secs(60),
sandbox.exec(&["python3", "-c", &script]),
)
.await
.map_err(|_| "route workload timed out".to_owned());
sandbox.cleanup().await;
output?
}
.await;
for name in &names {
run_cli_ignore_error(&["provider", "delete", name, "--yes"]).await;
run_cli_ignore_error(&["profile", "delete", name, "--yes"]).await;
}
let target_result = _target.drain().await;
_token_endpoint.shutdown().await;
_provider_spiffe.shutdown().await;
_gateway_spiffe.shutdown().await;
let output = result.expect("gateway-configured route workload succeeds");
target_result.expect("route target observes EOF on every accepted connection");
assert!(output.contains("route-recovery-a-b-a-ok"));
let requests = observed.lock().expect("route target observations lock");
assert_eq!(requests.len(), 3, "failed grant must not reach the target");
assert_eq!(
observed_bytes.load(Ordering::Relaxed),
requests.iter().map(|request| request.bytes).sum::<usize>(),
"no partial failed request reached the target"
);
for ((request, path), token) in requests
.iter()
.zip(["/a/first", "/b/second", "/a/third"])
.zip(["Bearer final-a", "Bearer final-b", "Bearer final-a"])
{
assert_eq!(request.path, path);
assert_eq!(
request.connection, requests[0].connection,
"upstream connection reused"
);
assert_eq!(
request.authorization.len(),
1,
"one Authorization replacement"
);
assert!(
request.authorization[0] == token,
"the admitted path owns the injected token"
);
}
assert_eq!(
*grants
.supervisor_requests
.lock()
.expect("route grant requests lock"),
["b", "a", "b"],
"failed grant retries; repeated A uses its own cached token"
);
}