chore: merge main into feat/stable-placeholders

Renumber ProviderProfileCredential.stable_placeholder to field 12 because main now uses field 11 for token_grant_owners. Regenerate the Go bindings from the merged proto, update the frozen schema fingerprints, and pass the new capability argument in the token-grant owner tests.

Signed-off-by: Shiju <shiju@nvidia.com>
This commit is contained in:
Shiju
2026-10-08 11:40:45 +05:30
87 changed files with 7662 additions and 894 deletions
@@ -125,6 +125,22 @@ gh run list --json databaseId,status,headBranch,url --jq '.[] | {id: .databaseId
## View Job Logs
For `Codex Security`, an exit code of 2 with partial coverage is an incomplete
scan, not a HIGH/CRITICAL threshold failure. Check the execution diagnostics in
the job summary and the `codex-security-diagnostics-<run>-<attempt>` artifact on
scan failure. It contains fixed status values, aggregate coverage counts, and
disk space before, after, and the minimum sampled every five seconds. Missing
coverage or manifest documents indicate no readable final report was available;
deferred items or surfaces needing follow-up can explain partial coverage.
Sampling cannot rule out a disk spike between samples.
Artifacts and logs in this public repository are public. Never upload the raw
scan directory, `report.md`, `coverage.json`, findings, agent state, or scanner
logs. Coverage reasons and notes are free text and may disclose vulnerabilities.
The public diagnostic intentionally omits them; detailed review needs an
approved private destination. Helpers must come from the workflow revision,
not from the candidate under scan.
`setup-nix` retries development-shell preparation once when `prepare-shell`
is enabled. Inspect both attempts in the job log; `setup-rust` assumes the
shell has already been prepared. Cargo, lint, and test commands are not retried.
+19 -3
View File
@@ -145,12 +145,13 @@ jobs:
sparse-checkout: tasks/scripts
persist-credentials: false
- name: Stage the range resolver
- name: Stage trusted scan helpers
run: |
set -euo pipefail
install -d -m 700 "$RUNNER_TEMP/range-resolver"
cp workflow-revision/tasks/scripts/release.py \
workflow-revision/tasks/scripts/codex_security_range.py \
workflow-revision/tasks/scripts/codex_security_diagnostics.py \
"$RUNNER_TEMP/range-resolver/"
rm -rf workflow-revision
@@ -185,6 +186,7 @@ jobs:
python3 "$RUNNER_TEMP/range-resolver/codex_security_range.py" "${args[@]}"
- name: Scan changes since the previous stable
id: scan
env:
BASE_SHA: ${{ steps.range.outputs.base_sha }}
CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security
@@ -206,7 +208,10 @@ jobs:
exit 2
fi
"$CODEX_SECURITY_BIN" scan . \
python3 "$RUNNER_TEMP/range-resolver/codex_security_diagnostics.py" \
--scan-dir "$SCAN_DIR" \
--output "$RUNNER_TEMP/codex-security-diagnostics.json" \
-- "$CODEX_SECURITY_BIN" scan . \
"${target_args[@]}" \
--auth api-key \
--model "$NVIDIA_INFERENCE_MODEL" \
@@ -220,7 +225,18 @@ jobs:
--codex "features.multi_agent_v2.max_concurrent_threads_per_session=$CODEX_SECURITY_MAX_CONCURRENT_THREADS" \
--codex 'approval_policy="never"' \
--output-dir "$SCAN_DIR" \
--headless > /dev/null
--headless
# Actions artifacts in this public repository are publicly downloadable.
# Publish only the helper's allowlisted projection, never SCAN_DIR or state.
- name: Upload public execution diagnostics on failure
if: ${{ failure() && steps.scan.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: codex-security-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/codex-security-diagnostics.json
if-no-files-found: warn
retention-days: 7
- name: Export SARIF
env:
Generated
+1
View File
@@ -4750,6 +4750,7 @@ dependencies = [
"noyalib",
"serde",
"serde_json",
"serde_path_to_error",
"thiserror 2.0.20",
]
+1
View File
@@ -85,6 +85,7 @@ socket2 = "0.6"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_path_to_error = "0.1"
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
toml = "0.8"
apollo-parser = "0.8.5"
+5 -1
View File
@@ -1781,7 +1781,11 @@ enum SandboxCommands {
#[arg(value_hint = ValueHint::AnyPath)]
local_path: String,
/// Destination path in the sandbox (defaults to the container's working directory).
/// Destination path in the sandbox (defaults to its working directory).
///
/// For an unfiltered single-file upload, an existing directory or a
/// trailing slash places the file inside that directory. A missing
/// nested path without a trailing slash renames the file.
dest: Option<String>,
/// Disable `.gitignore` filtering (uploads everything).
+16 -7
View File
@@ -1060,7 +1060,9 @@ pub async fn sandbox_create(
"\u{2022}".dimmed(),
);
}
sandbox_upload_planned(
// Boxed so the upload state machine lives on the heap instead of
// inflating the `sandbox_create` future (clippy::large_futures).
Box::pin(sandbox_upload_planned(
upload_plan,
&effective_server,
&sandbox_name,
@@ -1068,7 +1070,7 @@ pub async fn sandbox_create(
dest,
&effective_tls,
workspace,
)
))
.await
.wrap_err_with(|| {
format!(
@@ -5110,7 +5112,7 @@ async fn sandbox_upload_planned(
sandbox_path: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
) -> Result<Option<String>> {
match plan {
SandboxUploadPlan::GitAware { base_dir, files } => {
sandbox_sync_up_files(
@@ -5123,10 +5125,13 @@ async fn sandbox_upload_planned(
tls,
workspace,
)
.await
.await?;
Ok(None)
}
SandboxUploadPlan::Regular => {
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace).await
sandbox_sync_up(server, name, local_path, sandbox_path, tls, workspace)
.await
.map(Some)
}
}
}
@@ -5152,7 +5157,7 @@ pub async fn sandbox_upload(
dest_display
);
sandbox_upload_planned(
let uploaded_path = sandbox_upload_planned(
upload_plan,
server,
name,
@@ -5163,7 +5168,11 @@ pub async fn sandbox_upload(
)
.await?;
eprintln!("{} Upload complete", "✓".green().bold());
if let Some(path) = uploaded_path {
eprintln!("{} Upload complete: {path}", "✓".green().bold());
} else {
eprintln!("{} Upload complete", "✓".green().bold());
}
Ok(())
}
+500 -57
View File
@@ -63,6 +63,7 @@ const CONNECT_CHILD_TERMINATION_TIMEOUT: Duration = Duration::from_secs(5);
const SSH_TRANSPORT_FAILURE_EXIT_CODE: i32 = 255;
const SYNC_RETRY_ATTEMPTS: usize = 4;
const SYNC_RETRY_DELAY: Duration = Duration::from_secs(2);
const UPLOAD_DESTINATION_CHANGED_EXIT_CODE: i32 = 73;
#[derive(Clone, Copy, Debug)]
pub enum Editor {
@@ -1222,33 +1223,181 @@ fn local_upload_path_is_file_like(path: &Path) -> bool {
})
}
/// Core tar-over-SSH upload: streams a tar archive into `dest_dir` on the
/// sandbox. Callers are responsible for splitting the destination path so
/// that `dest_dir` is always a directory.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UploadDestinationKind {
Directory,
File,
Missing,
}
impl UploadDestinationKind {
fn as_str(self) -> &'static str {
match self {
Self::Directory => "directory",
Self::File => "file",
Self::Missing => "missing",
}
}
}
// Check every existing parent before interpreting a failed existence test as
// absence. An inaccessible directory, a file used as a parent, or a dangling
// parent symlink must fail inspection instead of selecting filename semantics.
// Only fixed tokens go to stdout; diagnostics remain on stderr.
const UPLOAD_DESTINATION_KIND_SH: &str = r#"upload_destination_kind() {
upload_path=$1
upload_parent=$2
while :; do
if [ -d "$upload_parent" ]; then
if [ ! -x "$upload_parent" ]; then
printf 'cannot inspect upload destination parent: %s\n' "$upload_parent" >&2
return 1
fi
elif [ -e "$upload_parent" ] || [ -L "$upload_parent" ]; then
printf 'upload destination parent is not a directory: %s\n' "$upload_parent" >&2
return 1
fi
case "$upload_parent" in
/|.) break ;;
*/*) upload_parent=${upload_parent%/*}; [ -n "$upload_parent" ] || upload_parent=/ ;;
*) upload_parent=. ;;
esac
done
if [ -d "$upload_path" ]; then
printf directory
elif [ -e "$upload_path" ] || [ -L "$upload_path" ]; then
printf file
else
printf missing
fi
}"#;
fn upload_destination_kind_invocation(path: &str) -> String {
let (parent, _) = split_sandbox_path(path);
format!(
"upload_destination_kind {} {}",
shell_escape(path),
shell_escape(parent),
)
}
fn file_upload_destination_needs_probe(path: &str) -> bool {
// Keep the existing /name heuristic: a top-level path means a directory.
!path.ends_with('/') && split_sandbox_path(path).0 != "/"
}
fn build_upload_destination_probe_cmd(path: &str) -> String {
format!(
"{UPLOAD_DESTINATION_KIND_SH}\n{}",
upload_destination_kind_invocation(path),
)
}
fn parse_upload_destination_kind(output: &str) -> Result<UploadDestinationKind> {
match output {
"directory" => Ok(UploadDestinationKind::Directory),
"file" => Ok(UploadDestinationKind::File),
"missing" => Ok(UploadDestinationKind::Missing),
_ => Err(miette::miette!(
"unexpected upload destination probe output: {output:?}"
)),
}
}
/// Existing directories receive the source basename. Everything else keeps
/// filename-renaming semantics, including dangling destination symlinks.
fn resolve_file_upload_destination<'a>(
path: &'a str,
source_basename: &std::ffi::OsStr,
kind: UploadDestinationKind,
) -> (&'a str, std::ffi::OsString) {
if kind == UploadDestinationKind::Directory {
(path, source_basename.to_os_string())
} else {
let (parent, basename) = split_sandbox_path(path);
(parent, basename.into())
}
}
fn build_upload_extract_cmd(
dest_dir: &str,
expected_destination: Option<(&str, UploadDestinationKind)>,
) -> String {
let escaped_dest = shell_escape(dest_dir);
let extract = format!("mkdir -p -- {escaped_dest} && cat | tar xf - -C {escaped_dest}");
let Some((path, kind)) = expected_destination else {
return extract;
};
// Recheck before mkdir or tar can replace a path whose type changed after
// planning. This detects changes, but is not atomic with extraction and
// does not pin a symlink's target against concurrent replacement.
format!(
"{UPLOAD_DESTINATION_KIND_SH}\n\
upload_kind=$({probe}) || exit 1\n\
if [ \"$upload_kind\" != {expected} ]; then\n\
printf 'upload destination changed type before extraction: %s\\n' {path} >&2\n\
exit {changed_status}\n\
fi\n\
{extract}",
probe = upload_destination_kind_invocation(path),
expected = kind.as_str(),
path = shell_escape(path),
changed_status = UPLOAD_DESTINATION_CHANGED_EXIT_CODE,
)
}
/// Stream an archive into the sandbox and return its final destination.
///
/// When `dest_dir` is `None`, tar extracts relative to the SSH session's
/// working directory.
/// Single files and symlinks use the remote destination type to choose between
/// placing the source inside a directory and renaming it. File-list uploads
/// retain their directory-only placement and return that directory.
async fn ssh_tar_upload(
server: &str,
name: &str,
dest_dir: Option<&str>,
source: UploadSource,
dest: Option<&str>,
mut source: UploadSource,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
) -> Result<String> {
let session = ssh_session_config(server, name, tls, workspace, None).await?;
let dest_dir = dest_dir.unwrap_or(".");
let escaped_dest = shell_escape(dest_dir);
let mut dest_dir = dest.unwrap_or(".");
let expected_destination = if let Some(path) = dest
&& let UploadSource::SinglePath {
local_path,
tar_name,
} = &mut source
&& local_upload_path_is_file_like(local_path)
&& file_upload_destination_needs_probe(path)
{
let output = ssh_run_capture_stdout(&session, &build_upload_destination_probe_cmd(path))
.await
.wrap_err_with(|| format!("failed to inspect upload destination '{path}'"))?;
let kind = parse_upload_destination_kind(&output)?;
let (directory, basename) = resolve_file_upload_destination(path, tar_name, kind);
dest_dir = directory;
*tar_name = basename;
Some((path, kind))
} else {
None
};
let final_path = match &source {
UploadSource::SinglePath { tar_name, .. } => {
format!(
"{}/{}",
dest_dir.trim_end_matches('/'),
tar_name.to_string_lossy()
)
}
UploadSource::FileList { .. } => dest_dir.to_string(),
};
let mut ssh = ssh_base_command(&session.proxy_command);
ssh.arg("-T")
.arg("-o")
.arg("RequestTTY=no")
.arg("sandbox")
.arg(format!(
"mkdir -p {escaped_dest} && cat | tar xf - -C {escaped_dest}",
))
.arg(build_upload_extract_cmd(dest_dir, expected_destination))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit());
@@ -1259,23 +1408,30 @@ async fn ssh_tar_upload(
.take()
.ok_or_else(|| miette::miette!("failed to open stdin for ssh process"))?;
// Build the tar archive in a blocking task since the tar crate is synchronous.
tokio::task::spawn_blocking(move || -> Result<()> { write_upload_archive(stdin, source) })
.await
.into_diagnostic()??;
// Wait even when the writer gets a broken pipe. A failed remote guard can
// close stdin early; its status must not become a retryable write error.
let archive_result =
tokio::task::spawn_blocking(move || -> Result<()> { write_upload_archive(stdin, source) })
.await
.into_diagnostic()?;
let status = tokio::task::spawn_blocking(move || child.wait())
.await
.into_diagnostic()?
.into_diagnostic()?;
if status.code() == Some(UPLOAD_DESTINATION_CHANGED_EXIT_CODE) {
return Err(miette::miette!(
"upload destination changed type before extraction; retry the upload"
));
}
if !status.success() {
return Err(miette::miette!(
"ssh tar extract exited with status {status}"
));
}
archive_result?;
Ok(())
Ok(final_path)
}
/// Split a sandbox path into (`parent_directory`, basename).
@@ -1455,15 +1611,15 @@ pub async fn sandbox_sync_up_files(
async move { ssh_tar_upload(server, name, dest, source, tls, workspace).await }
})
.await
.map(|_| ())
}
/// Push a local path (file or directory) into a sandbox using tar-over-SSH.
///
/// When `sandbox_path` is `None`, files are uploaded to the SSH session's
/// working directory. When uploading a single file to an explicit destination
/// that does not end with `/`, the destination is treated as a file path: the
/// parent directory is created and the file is written with the destination's
/// basename. This matches `cp` / `scp` semantics.
/// working directory. A single file or symlink goes inside an existing remote
/// directory, even without a trailing slash. Other nested no-slash destinations
/// rename the source. Returns the final path after a successful upload.
pub async fn sandbox_sync_up(
server: &str,
name: &str,
@@ -1471,37 +1627,8 @@ pub async fn sandbox_sync_up(
sandbox_path: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
// When an explicit destination is given and looks like a file path (does
// not end with '/'), split into parent directory + target basename so that
// `mkdir -p` creates the parent and tar extracts the file with the right
// name.
//
// Exception: if splitting would yield "/" as the parent, fall through to
// directory semantics instead. The sandbox user cannot write to "/" and
// the intent is almost certainly to place the file inside the named
// top-level directory.
) -> Result<String> {
let local_path_is_file_like = local_upload_path_is_file_like(local_path);
if let Some(path) = sandbox_path
&& local_path_is_file_like
&& !path.ends_with('/')
{
let (parent, target_name) = split_sandbox_path(path);
if parent != "/" {
let source = UploadSource::SinglePath {
local_path: local_path.to_path_buf(),
tar_name: target_name.into(),
};
return retry_sandbox_sync("upload", || {
let source = source.clone();
async move {
ssh_tar_upload(server, name, Some(parent), source, tls, workspace).await
}
})
.await;
}
}
let tar_name = if local_path_is_file_like {
local_path
.file_name()
@@ -1688,15 +1815,15 @@ async fn sandbox_sync_down_once(
}
}
async fn retry_sandbox_sync<F, Fut>(operation: &str, mut run: F) -> Result<()>
async fn retry_sandbox_sync<F, Fut, T>(operation: &str, mut run: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<()>>,
Fut: Future<Output = Result<T>>,
{
let mut attempt = 1;
loop {
match run().await {
Ok(()) => return Ok(()),
Ok(value) => return Ok(value),
Err(error) if attempt < SYNC_RETRY_ATTEMPTS && sync_error_is_retryable(&error) => {
tracing::warn!(
operation,
@@ -2999,6 +3126,322 @@ mod tests {
assert_eq!(fs::read(&final_path).expect("read final"), b"trust me");
}
#[cfg(unix)]
fn run_upload_shell(
command: &str,
workdir: &Path,
archive: Option<&[u8]>,
) -> std::process::Output {
let mut child = Command::new("sh")
.args(["-c", command])
.current_dir(workdir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("start upload shell");
let write_result = child
.stdin
.take()
.map(|mut stdin| stdin.write_all(archive.unwrap_or_default()));
let output = child.wait_with_output().expect("wait for upload shell");
if output.status.success() {
write_result.unwrap().expect("write upload archive");
}
output
}
#[cfg(unix)]
fn prepare_file_upload_command(
local_path: &Path,
dest: &str,
workdir: &Path,
) -> (String, Vec<u8>) {
let basename = local_path.file_name().expect("source basename");
let (directory, tar_name, expected) = if file_upload_destination_needs_probe(dest) {
let probe = run_upload_shell(&build_upload_destination_probe_cmd(dest), workdir, None);
assert!(
probe.status.success(),
"destination probe failed: {}",
String::from_utf8_lossy(&probe.stderr)
);
let kind = parse_upload_destination_kind(
&decode_ssh_probe_stdout(probe.stdout).expect("decode probe"),
)
.expect("destination kind");
let (directory, tar_name) = resolve_file_upload_destination(dest, basename, kind);
(directory, tar_name, Some((dest, kind)))
} else {
(dest, basename.to_os_string(), None)
};
let mut archive = Vec::new();
write_upload_archive(
&mut archive,
UploadSource::SinglePath {
local_path: local_path.to_path_buf(),
tar_name,
},
)
.expect("write upload archive");
(build_upload_extract_cmd(directory, expected), archive)
}
#[cfg(unix)]
fn upload_file_locally(local_path: &Path, dest: &str, workdir: &Path) {
let (command, archive) = prepare_file_upload_command(local_path, dest, workdir);
let output = run_upload_shell(&command, workdir, Some(&archive));
assert!(
output.status.success(),
"upload failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(unix)]
#[test]
fn upload_into_existing_empty_directory_preserves_directory() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("config.json");
fs::write(&source, b"configuration").unwrap();
let directory = tmp.path().join("gym");
fs::create_dir(&directory).unwrap();
upload_file_locally(&source, directory.to_str().unwrap(), tmp.path());
assert!(directory.is_dir(), "upload replaced the existing directory");
assert_eq!(
fs::read(directory.join("config.json")).unwrap(),
b"configuration"
);
}
#[cfg(unix)]
#[test]
fn upload_preserves_nonempty_directory_and_directory_symlink() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("config.json");
fs::write(&source, b"configuration").unwrap();
let directory = tmp.path().join("gym");
fs::create_dir(&directory).unwrap();
fs::write(directory.join("keep.txt"), b"keep").unwrap();
let link = tmp.path().join("linked-gym");
std::os::unix::fs::symlink("gym", &link).unwrap();
for destination in [&directory, &link] {
upload_file_locally(&source, destination.to_str().unwrap(), tmp.path());
assert_eq!(
fs::read(directory.join("config.json")).unwrap(),
b"configuration"
);
assert_eq!(fs::read(directory.join("keep.txt")).unwrap(), b"keep");
}
assert_eq!(fs::read_link(&link).unwrap(), Path::new("gym"));
}
#[cfg(unix)]
#[test]
fn upload_retains_file_renames_overwrites_and_trailing_slash_placement() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("config.json");
fs::write(&source, b"configuration").unwrap();
fs::write(tmp.path().join("existing.json"), b"old").unwrap();
for dest in ["missing/renamed.json", "existing.json", "bare-name"] {
upload_file_locally(&source, dest, tmp.path());
assert_eq!(fs::read(tmp.path().join(dest)).unwrap(), b"configuration");
}
upload_file_locally(&source, "new-directory/", tmp.path());
assert_eq!(
fs::read(tmp.path().join("new-directory/config.json")).unwrap(),
b"configuration"
);
}
#[cfg(unix)]
#[test]
fn upload_preserves_dangling_source_symlink_and_replaces_dangling_destination() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source-link");
std::os::unix::fs::symlink("missing-target", &source).unwrap();
let directory = tmp.path().join("gym");
fs::create_dir(&directory).unwrap();
upload_file_locally(&source, "gym", tmp.path());
assert_eq!(
fs::read_link(directory.join("source-link")).unwrap(),
Path::new("missing-target")
);
let regular_source = tmp.path().join("config.json");
fs::write(&regular_source, b"configuration").unwrap();
let dangling_dest = tmp.path().join("dangling-dest");
std::os::unix::fs::symlink("missing-target", &dangling_dest).unwrap();
upload_file_locally(&regular_source, "dangling-dest", tmp.path());
assert!(fs::symlink_metadata(&dangling_dest).unwrap().is_file());
assert_eq!(fs::read(&dangling_dest).unwrap(), b"configuration");
assert!(!tmp.path().join("missing-target").exists());
}
#[cfg(unix)]
#[test]
fn upload_quotes_shell_metacharacters_and_leading_dashes() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("config '$(touch source-injected)'\n.json");
fs::write(&source, b"configuration").unwrap();
let dest = "-gym '$(touch destination-injected)'\n";
let directory = tmp.path().join(dest);
fs::create_dir(&directory).unwrap();
upload_file_locally(&source, dest, tmp.path());
assert_eq!(
fs::read(directory.join(source.file_name().unwrap())).unwrap(),
b"configuration"
);
assert!(!tmp.path().join("source-injected").exists());
assert!(!tmp.path().join("destination-injected").exists());
}
#[cfg(unix)]
#[test]
fn upload_probe_rejects_invalid_parents_instead_of_reporting_missing() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("file-parent"), b"keep").unwrap();
std::os::unix::fs::symlink("missing-parent", tmp.path().join("link-parent")).unwrap();
for dest in [
"file-parent/child",
"file-parent/nested/child",
"link-parent/child",
] {
let output =
run_upload_shell(&build_upload_destination_probe_cmd(dest), tmp.path(), None);
assert!(!output.status.success(), "invalid parent accepted: {dest}");
assert!(
output.stdout.is_empty(),
"probe must not emit missing on failure"
);
assert!(String::from_utf8_lossy(&output.stderr).contains("parent is not a directory"));
}
assert!(parse_upload_destination_kind("").is_err());
assert!(parse_upload_destination_kind("directory\nunexpected").is_err());
}
#[cfg(unix)]
#[test]
fn upload_probe_rejects_unsearchable_parent_instead_of_reporting_missing() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("private");
fs::create_dir(&parent).unwrap();
fs::set_permissions(&parent, fs::Permissions::from_mode(0o600)).unwrap();
// Root can search this directory despite its mode, so run the denial
// assertion only when the test process is subject to these permissions.
let can_search = run_upload_shell("test -x private", tmp.path(), None)
.status
.success();
let output = run_upload_shell(
&build_upload_destination_probe_cmd("private/nested/config.json"),
tmp.path(),
None,
);
fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap();
if !can_search {
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot inspect"));
}
}
#[cfg(unix)]
#[test]
fn upload_rejects_detected_destination_type_changes_before_extraction() {
for initial_kind in [
UploadDestinationKind::Missing,
UploadDestinationKind::File,
UploadDestinationKind::Directory,
] {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("config.json");
fs::write(&source, b"configuration").unwrap();
let destination = tmp.path().join("destination");
match initial_kind {
UploadDestinationKind::Directory => fs::create_dir(&destination).unwrap(),
UploadDestinationKind::File => fs::write(&destination, b"old").unwrap(),
UploadDestinationKind::Missing => {}
}
let (command, archive) =
prepare_file_upload_command(&source, "destination", tmp.path());
if initial_kind == UploadDestinationKind::Directory {
fs::remove_dir(&destination).unwrap();
fs::write(&destination, b"replacement").unwrap();
} else {
if initial_kind == UploadDestinationKind::File {
fs::remove_file(&destination).unwrap();
}
fs::create_dir(&destination).unwrap();
fs::write(destination.join("keep.txt"), b"keep").unwrap();
}
let output = run_upload_shell(&command, tmp.path(), Some(&archive));
assert_eq!(
output.status.code(),
Some(UPLOAD_DESTINATION_CHANGED_EXIT_CODE)
);
assert!(String::from_utf8_lossy(&output.stderr).contains("changed type"));
if initial_kind == UploadDestinationKind::Directory {
assert_eq!(fs::read(&destination).unwrap(), b"replacement");
} else {
assert_eq!(fs::read(destination.join("keep.txt")).unwrap(), b"keep");
assert!(!destination.join("config.json").exists());
}
}
assert!(!sync_error_is_retryable(&miette::miette!(
"upload destination changed type before extraction; retry the upload"
)));
}
#[cfg(unix)]
#[test]
fn upload_file_list_keeps_selection_and_directory_prefix() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path().join("source");
fs::create_dir(&source).unwrap();
fs::write(source.join("selected.json"), b"selected").unwrap();
fs::write(source.join("ignored.json"), b"ignored").unwrap();
let mut archive = Vec::new();
write_upload_archive(
&mut archive,
UploadSource::FileList {
base_dir: source,
files: vec!["selected.json".into()],
archive_prefix: Some("source".into()),
},
)
.unwrap();
let output = run_upload_shell(
&build_upload_extract_cmd("destination", None),
tmp.path(),
Some(&archive),
);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read(tmp.path().join("destination/source/selected.json")).unwrap(),
b"selected"
);
assert!(!tmp.path().join("destination/source/ignored.json").exists());
}
#[test]
fn upload_destination_probe_preserves_explicit_directory_and_top_level_rules() {
for path in ["/sandbox", "/sandbox/", "relative/", "/"] {
assert!(!file_upload_destination_needs_probe(path), "{path}");
}
for path in ["/sandbox/config.json", "relative", ".", "nested/directory"] {
assert!(file_upload_destination_needs_probe(path), "{path}");
}
}
#[test]
fn directory_upload_prefix_uses_basename_for_named_directories() {
assert_eq!(
@@ -56,6 +56,7 @@ fn run_file_transfer(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
Box::pin(async move {
let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?;
upload_existing_directories(runner, &sandbox_name, &remote_root, local.path()).await?;
round_trip(runner, &sandbox_name, &remote_root, local.path()).await?;
download_file(runner, &sandbox_name, &remote_root, local.path()).await?;
download_directory(runner, &sandbox_name, &remote_root, local.path()).await?;
@@ -132,6 +133,81 @@ async fn delete_sandbox(runner: &mut OpenShellRunner, sandbox_name: &str) -> Res
Ok(())
}
/// Check remote destination types, not just successful transfer status: tar
/// can report success after replacing an empty directory with a regular file.
async fn upload_existing_directories(
runner: &OpenShellRunner,
sandbox: &str,
remote_root: &str,
local_root: &Path,
) -> Result<(), String> {
let source = local_root.join("config.json");
fs::write(&source, "upload-destination-payload")
.map_err(fs_error("write destination fixture"))?;
let root = format!("{remote_root}/upload destinations");
exec(
runner,
sandbox,
"upload-destinations/prepare",
&format!(
"mkdir -p '{root}/empty' '{root}/populated' '{root}/target' '{root}/slash' && \
printf keep > '{root}/populated/keep.txt' && \
ln -s target '{root}/link'"
),
)
.await?;
for directory in ["empty", "populated", "link", "slash/"] {
let destination = format!("{root}/{directory}");
upload(
runner,
sandbox,
&format!("upload-destinations/{directory}/upload"),
&source,
&destination,
true,
)
.await?;
exec(
runner,
sandbox,
&format!("upload-destinations/{directory}/verify"),
&format!(
"test -d '{destination}' && \
test \"$(cat '{destination}/config.json')\" = upload-destination-payload && \
test \"$(cat '{root}/populated/keep.txt')\" = keep && \
test -L '{root}/link'"
),
)
.await?;
}
// A missing destination remains an intentional rename; uploading again
// must replace that file rather than treating it as a directory.
let renamed = format!("{root}/renamed.json");
for pass in ["rename", "overwrite"] {
upload(
runner,
sandbox,
&format!("upload-destinations/{pass}/upload"),
&source,
&renamed,
true,
)
.await?;
exec(
runner,
sandbox,
&format!("upload-destinations/{pass}/verify"),
&format!(
"test -f '{renamed}' && test \"$(cat '{renamed}')\" = upload-destination-payload"
),
)
.await?;
}
Ok(())
}
async fn round_trip(
runner: &OpenShellRunner,
sandbox: &str,
+45
View File
@@ -84,3 +84,48 @@ impl tonic::service::Interceptor for EdgeAuthInterceptor {
Ok(req)
}
}
/// Return the credential from an `authorization` header value that uses the
/// `Bearer` scheme.
///
/// The scheme is matched ASCII case-insensitively and may be followed by more
/// than one space (RFC 9110 section 11.1). The credential is returned
/// verbatim, so callers keep their own validation.
#[must_use]
pub fn strip_bearer_scheme(value: &str) -> Option<&str> {
let (scheme, credential) = value.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| credential.trim_start_matches(' '))
}
#[cfg(test)]
mod tests {
use super::strip_bearer_scheme;
#[test]
fn bearer_scheme_is_case_insensitive() {
for scheme in ["Bearer", "bearer", "BEARER", "bEaReR"] {
assert_eq!(strip_bearer_scheme(&format!("{scheme} abc")), Some("abc"));
}
}
#[test]
fn bearer_scheme_allows_repeated_spaces() {
assert_eq!(strip_bearer_scheme("Bearer abc"), Some("abc"));
}
#[test]
fn bearer_scheme_returns_credential_verbatim() {
assert_eq!(strip_bearer_scheme("Bearer "), Some(""));
assert_eq!(strip_bearer_scheme("Bearer a b"), Some("a b"));
assert_eq!(strip_bearer_scheme("Bearer abc "), Some("abc "));
}
#[test]
fn other_schemes_are_not_bearer() {
for value in ["Basic abc", "Bearerabc", "Bearer", "", "Bearer\tabc", "abc"] {
assert_eq!(strip_bearer_scheme(value), None, "{value:?}");
}
}
}
@@ -207,6 +207,25 @@ pub fn validate_workspace_mount_target(target: &str, workspace_root: &str) -> Re
Ok(())
}
/// Validate mount targets against a workspace and driver-specific control paths.
///
/// Targets must already pass container mount-target validation. This check
/// applies regardless of their native mount type. Mounts below the workspace
/// remain valid; control paths reject overlaps in either direction.
pub fn validate_workspace_mount_targets<'a>(
targets: impl IntoIterator<Item = &'a str>,
workspace_root: &str,
control_paths: &[&str],
) -> Result<(), String> {
for target in targets {
validate_workspace_mount_target(target, workspace_root)?;
for control_path in control_paths {
validate_mount_control_path(target, control_path)?;
}
}
Ok(())
}
/// Normalize a validated container-side mount target for semantic comparison.
pub fn normalize_mount_target(target: &str) -> String {
if target == "/" {
@@ -244,6 +263,44 @@ mod tests {
validate_workspace_mount_target("/workspace-other", "/workspace/project").unwrap();
}
#[test]
fn workspace_mount_targets_allow_empty_and_nested_targets() {
validate_workspace_mount_targets([], "/workspace", &["/.openshell"]).unwrap();
validate_workspace_mount_targets(
["/workspace/cache", "/workspace/data", "/workspace-other"],
"/workspace",
&["/.openshell"],
)
.unwrap();
}
#[test]
fn workspace_mount_targets_reject_covering_mounts_and_control_paths() {
for target in ["/workspace/project/", "/workspace"] {
let error = validate_workspace_mount_targets(
["/workspace/project/cache", target],
"/workspace/project",
&["/.openshell"],
)
.unwrap_err();
assert!(error.contains("reserved for the OpenShell workspace"));
}
for target in [
"/.openshell",
"/.openshell/channel",
"/custom",
"/custom/socket",
] {
let error = validate_workspace_mount_targets(
["/workspace/cache", target],
"/workspace",
&["/.openshell", "/custom/socket"],
)
.unwrap_err();
assert!(error.contains("conflicts with OpenShell control path"));
}
}
#[test]
fn oci_workspace_root_uses_fallback_and_accepts_normalized_absolute_paths() {
assert_eq!(resolve_oci_workspace_root("").unwrap(), "/sandbox");
@@ -0,0 +1,327 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Keys for endpoint-bound dynamic credentials.
//!
//! The gateway encodes one key per credential, endpoint selector, and owning
//! endpoint. The supervisor matches requests against the endpoint selector,
//! tells credentials apart by their trailing identity, and scopes each key to
//! one credential generation. Keeping all three operations here means the key
//! layout is defined in one place.
//!
//! Layout: `host \t port \t path \t owner \t provider:credential`. A scoped key
//! inserts `rev:<revision> \t installation:<id>` before the trailing identity.
use crate::provider_credentials::ProviderCredentialSnapshot;
/// A validated endpoint-bound credential key. Fields cannot contain control
/// characters, including the tab used to separate encoded components.
pub struct DynamicCredentialKey<'a> {
host: &'a str,
port: u32,
path: &'a str,
owner: &'a str,
provider_name: &'a str,
credential_name: &'a str,
}
impl<'a> DynamicCredentialKey<'a> {
/// Validates components before they can be encoded into a map or cache key.
///
/// # Errors
/// Returns an error for control characters, empty required fields, invalid
/// ports, or a provider name containing the credential identity separator.
pub fn new(
host: &'a str,
port: u32,
path: &'a str,
owner: &'a str,
provider_name: &'a str,
credential_name: &'a str,
) -> miette::Result<Self> {
for (field, value) in [
("host", host),
("path", path),
("owner", owner),
("provider name", provider_name),
("credential name", credential_name),
] {
if value.chars().any(char::is_control) {
return Err(miette::miette!(
"dynamic credential key {field} contains a control character"
));
}
if field != "path" && value.is_empty() {
return Err(miette::miette!("dynamic credential key {field} is empty"));
}
}
if provider_name.contains(':') || !(1..=u32::from(u16::MAX)).contains(&port) {
return Err(miette::miette!(
"dynamic credential key has an invalid provider name or port"
));
}
Ok(Self {
host,
port,
path,
owner,
provider_name,
credential_name,
})
}
/// Encodes the key. Host matching is case-insensitive, so the host is
/// lowercased to keep equal selectors on equal keys.
#[must_use]
pub fn encode(&self) -> String {
format!(
"{}\t{}\t{}\t{}\t{}:{}",
self.host.to_ascii_lowercase(),
self.port,
self.path,
self.owner,
self.provider_name,
self.credential_name
)
}
}
/// The endpoint selector at the front of a key, as written in the key.
pub struct EndpointSelector<'a> {
pub host: &'a str,
pub port: &'a str,
pub path: &'a str,
}
/// Reads an endpoint selector from a supported key layout. Malformed keys and
/// keys without an endpoint selector return `None`.
#[must_use]
pub fn endpoint_selector(key: &str) -> Option<EndpointSelector<'_>> {
decode(key)?.0
}
/// Reads the trailing `provider:credential` identity of a valid key.
///
/// Endpoint and generation fields do not change the credential's identity.
/// Malformed keys return `None`.
#[must_use]
pub fn credential_identity(key: &str) -> Option<&str> {
Some(decode(key)?.1)
}
fn decode(key: &str) -> Option<(Option<EndpointSelector<'_>>, &str)> {
// A stack buffer bounds parsing and avoids allocating on each request.
let mut fields = [""; 7];
let mut count = 0;
for field in key.split('\t') {
if count == fields.len() || field.chars().any(char::is_control) {
return None;
}
fields[count] = field;
count += 1;
}
let (identity, prefix) = fields[..count].split_last()?;
let (provider, credential) = identity.split_once(':')?;
if provider.is_empty() || credential.is_empty() {
return None;
}
let valid_revision = |value: &str| {
value
.strip_prefix("rev:")
.is_some_and(|revision| revision.parse::<u64>().is_ok())
};
let valid_installation = |value: &str| {
value
.strip_prefix("installation:")
.is_some_and(|id| !id.is_empty())
};
match prefix {
[] => Some((None, identity)),
[revision, installation]
if valid_revision(revision) && valid_installation(installation) =>
{
Some((None, identity))
}
[host, port, path, rest @ ..] => {
// Accept legacy selector-only keys and revision-only cache keys,
// as well as keys carrying endpoint ownership and installation ID.
let valid_suffix = match rest {
[] => true,
[owner_or_revision] => !owner_or_revision.is_empty(),
[revision, installation] => {
valid_revision(revision) && valid_installation(installation)
}
[owner, revision, installation] => {
!owner.is_empty()
&& valid_revision(revision)
&& valid_installation(installation)
}
_ => false,
};
if host.is_empty()
|| port.parse::<u16>().ok().is_none_or(|port| port == 0)
|| !valid_suffix
{
return None;
}
Some((Some(EndpointSelector { host, port, path }), identity))
}
_ => None,
}
}
impl ProviderCredentialSnapshot {
/// Scopes `key` to this snapshot's revision and installation, so cached
/// tokens never cross a credential generation. A key without a tab has no
/// endpoint selector and is scoped as a whole.
#[must_use]
pub fn scoped_key(&self, key: &str) -> String {
match key.rsplit_once('\t') {
Some((selector, identity)) => format!(
"{selector}\trev:{}\tinstallation:{}\t{identity}",
self.revision, self.installation_id
),
None => format!(
"rev:{}\tinstallation:{}\t{key}",
self.revision, self.installation_id
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> String {
DynamicCredentialKey::new(
"API.Example.com",
443,
"/v1/**",
"owner-a",
"provider",
"access_token",
)
.expect("valid components")
.encode()
}
#[test]
fn encoding_round_trips_selector_and_identity() {
assert_eq!(
key(),
"api.example.com\t443\t/v1/**\towner-a\tprovider:access_token"
);
let key = key();
let selector = endpoint_selector(&key).expect("encoded key has a selector");
assert_eq!(
(selector.host, selector.port, selector.path),
("api.example.com", "443", "/v1/**")
);
assert_eq!(credential_identity(&key), Some("provider:access_token"));
}
#[test]
fn construction_rejects_unsafe_components() {
for invalid in ["a\tshared", "a\nshared", "a\rshared", "a\0shared"] {
for position in 0..5 {
let mut fields = ["api.example.com", "/v1/**", "owner", "provider", "access"];
fields[position] = invalid;
assert!(
DynamicCredentialKey::new(
fields[0], 443, fields[1], fields[2], fields[3], fields[4]
)
.is_err(),
"field {position}"
);
}
}
for port in [0, 65_536] {
assert!(
DynamicCredentialKey::new(
"api.example.com",
port,
"",
"owner",
"provider",
"access"
)
.is_err()
);
}
assert!(
DynamicCredentialKey::new(
"api.example.com",
443,
"",
"owner",
"provider:other",
"access"
)
.is_err()
);
}
#[test]
fn decoding_preserves_supported_layouts_and_rejects_malformed_keys() {
for key in [
"provider:access",
"rev:7\tinstallation:id\tprovider:access",
"api.example.com\t443\t\tprovider:access",
"api.example.com\t443\t/v1/**\trev:7\tprovider:access",
"api.example.com\t443\t/v1/**\trev:7\tinstallation:id\tprovider:access",
"api.example.com\t443\t/v1/**\towner\tprovider:access",
"api.example.com\t443\t/v1/**\towner\trev:7\tinstallation:id\tprovider:access",
] {
assert_eq!(credential_identity(key), Some("provider:access"), "{key:?}");
assert_eq!(
endpoint_selector(key).is_some(),
key.starts_with("api.example.com")
);
}
for key in [
"host\t443\t/v1/**",
"provider:",
":access",
"api.example.com\t0\t\tprovider:access",
"api.example.com\t443\t/v1/**\towner\tprovider:a\tshared",
"api.example.com\t443\t/v1/**\towner\tprovider:a\tother:access",
"api.example.com\t443\t/v1/**\towner\trev:no\tinstallation:id\tprovider:access",
"api.example.com\t443\t/v1/**\towner\trev:7\tinstallation:\tprovider:access",
"api.example.com\t443\t/v1/**\towner\trev:7\tinstallation:id\tprovider:a\tshared",
"api.example.com\t443\t/v1/**\towner\tprovider:a\nshared",
] {
assert!(credential_identity(key).is_none(), "{key:?}");
assert!(endpoint_selector(key).is_none(), "{key:?}");
}
}
#[test]
fn scoping_inserts_the_generation_before_the_identity() {
let snapshot = ProviderCredentialSnapshot {
installation_id: "install".into(),
revision: 7,
..Default::default()
};
let scoped = snapshot.scoped_key(&key());
assert_eq!(
scoped,
"api.example.com\t443\t/v1/**\towner-a\trev:7\tinstallation:install\tprovider:access_token"
);
// Scoping must not disturb selector parsing or credential identity.
assert_eq!(
endpoint_selector(&scoped).map(|selector| selector.path),
Some("/v1/**")
);
assert_eq!(credential_identity(&scoped), Some("provider:access_token"));
assert_eq!(
snapshot.scoped_key("provider:access_token"),
"rev:7\tinstallation:install\tprovider:access_token"
);
// A leading tab is an empty selector, not a missing one.
assert_eq!(
snapshot.scoped_key("\tprovider:access_token"),
"\trev:7\tinstallation:install\tprovider:access_token"
);
}
}
+1 -2
View File
@@ -679,14 +679,13 @@ fn compute_refresh_delay(slot: &TokenSlot) -> Option<Duration> {
.ok()
.and_then(|v| v.to_str().ok().map(str::to_string))
.unwrap_or_default();
let bearer = token.strip_prefix("Bearer ").unwrap_or(&token);
let now_ms = i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis()),
)
.unwrap_or(i64::MAX);
let expires_at = parse_jwt_exp_ms(bearer);
let expires_at = parse_jwt_exp_ms(&token);
if expires_at == Some(0) {
return None;
}
+2 -1
View File
@@ -18,7 +18,7 @@
#[must_use]
pub fn parse_exp_secs(token: &str) -> Option<i64> {
use base64::Engine;
let raw = token.strip_prefix("Bearer ").unwrap_or(token);
let raw = crate::auth::strip_bearer_scheme(token).unwrap_or(token);
let mut parts = raw.splitn(3, '.');
let _header = parts.next()?;
let payload_b64 = parts.next()?;
@@ -923,6 +923,7 @@ mod tests {
sub: None,
});
assert_eq!(parse_exp_secs(&format!("Bearer {token}")), Some(42));
assert_eq!(parse_exp_secs(&format!("bearer {token}")), Some(42));
}
#[test]
+1
View File
@@ -16,6 +16,7 @@ pub mod container_paths;
pub mod denial;
pub mod driver_mounts;
pub mod driver_utils;
pub mod dynamic_credential_key;
pub mod dynamic_string_allowlist;
#[cfg(unix)]
pub mod e2fsprogs;
@@ -175,3 +175,165 @@ fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec<u8> {
pub fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String {
format!("{:x}", Sha256::digest(canonical_policy_bytes(policy)))
}
/// Derive token-grant authorities from a provider record and its complete profile rule.
///
/// The record identity separates providers whose names sanitize to the same policy
/// key. Hashing the rule content and endpoint position prevents a refreshed profile
/// from using grants through an older policy generation. Generated names and
/// provenance are excluded so both gateway construction paths derive the same value.
pub fn provider_token_grant_owners(provider_id: &str, rule: &NetworkPolicyRule) -> Vec<String> {
let mut authority = rule.clone();
authority.name.clear();
clear_rule_token_grant_provenance(&mut authority);
let rule_bytes = canonical_rule_bytes(&authority);
authority
.endpoints
.iter()
.enumerate()
.map(|(index, _)| {
token_grant_owner(
b"openshell:provider-token-grant-owner:v1",
provider_id.as_bytes(),
&rule_bytes,
index,
)
})
.collect()
}
/// Stamp endpoint authorities for a complete gateway-global policy replacement.
///
/// Global policy suppresses provider profile authorization rules. Its own endpoint
/// authorities must therefore be used for grants, while the original credential
/// host, port, and path selectors continue to limit where a token may be sent.
/// The complete policy hash invalidates every stamp when the global policy changes.
pub fn stamp_global_token_grant_owners(policy: &mut ProtoSandboxPolicy) {
let mut authority = policy.clone();
for rule in authority.network_policies.values_mut() {
for endpoint in &mut rule.endpoints {
endpoint.token_grant_owner.clear();
endpoint.provider_credentialed = false;
}
}
let policy_hash = deterministic_policy_hash(&authority);
for (name, rule) in &mut policy.network_policies {
for (index, endpoint) in rule.endpoints.iter_mut().enumerate() {
endpoint.token_grant_owner = token_grant_owner(
b"openshell:global-token-grant-owner:v1",
policy_hash.as_bytes(),
name.as_bytes(),
index,
);
}
}
}
fn clear_rule_token_grant_provenance(rule: &mut NetworkPolicyRule) {
for endpoint in &mut rule.endpoints {
endpoint.token_grant_owner.clear();
endpoint.provider_credentialed = false;
endpoint.advisor_proposed = false;
}
}
fn token_grant_owner(domain: &[u8], identity: &[u8], policy: &[u8], index: usize) -> String {
let mut input = Vec::new();
append_canonical_bytes(&mut input, domain);
append_canonical_bytes(&mut input, identity);
append_canonical_bytes(&mut input, policy);
input.extend_from_slice(&canonical_size(index));
format!("grant-owner:v1:{:x}", Sha256::digest(input))
}
#[cfg(test)]
mod token_grant_owner_tests {
use super::*;
use crate::proto::{NetworkBinary, NetworkEndpoint};
fn profile_rule() -> NetworkPolicyRule {
NetworkPolicyRule {
endpoints: vec![
NetworkEndpoint {
host: "api.example.test".into(),
path: "/public/**".into(),
..Default::default()
},
NetworkEndpoint {
host: "api.example.test".into(),
path: "/private/**".into(),
..Default::default()
},
],
binaries: vec![NetworkBinary {
path: "/usr/bin/client".into(),
}],
..Default::default()
}
}
#[test]
fn provider_owner_changes_with_identity_profile_or_endpoint_position() {
let rule = profile_rule();
let original = provider_token_grant_owners("provider-a", &rule);
assert_ne!(original[0], original[1]);
assert_ne!(original, provider_token_grant_owners("provider-b", &rule));
let mut changed = rule.clone();
changed.endpoints.swap(0, 1);
assert!(
!provider_token_grant_owners("provider-a", &changed)
.iter()
.any(|owner| original.contains(owner))
);
changed = rule.clone();
changed.binaries[0].path = "/usr/bin/other-client".into();
assert_ne!(
original,
provider_token_grant_owners("provider-a", &changed)
);
changed = rule;
changed.endpoints[0].access = 3;
assert_ne!(
original,
provider_token_grant_owners("provider-a", &changed)
);
}
#[test]
fn provider_owner_ignores_generated_names_and_untrusted_provenance() {
let mut rule = profile_rule();
let owners = provider_token_grant_owners("provider-a", &rule);
rule.name = "_provider_colliding_name_2".into();
for endpoint in &mut rule.endpoints {
endpoint.token_grant_owner = "forged".into();
endpoint.provider_credentialed = true;
endpoint.advisor_proposed = true;
}
assert_eq!(owners, provider_token_grant_owners("provider-a", &rule));
}
#[test]
fn global_owner_rebinds_on_policy_change_without_mutating_provenance() {
let mut policy = ProtoSandboxPolicy {
network_policies: HashMap::from([("global".into(), profile_rule())]),
..Default::default()
};
let endpoint = &mut policy.network_policies.get_mut("global").unwrap().endpoints[0];
endpoint.advisor_proposed = true;
endpoint.provider_credentialed = true;
stamp_global_token_grant_owners(&mut policy);
let first = policy.network_policies["global"].endpoints[0].clone();
assert!(first.advisor_proposed && first.provider_credentialed);
stamp_global_token_grant_owners(&mut policy);
assert_eq!(first, policy.network_policies["global"].endpoints[0]);
policy.network_policies.get_mut("global").unwrap().binaries[0].path =
"/usr/bin/replacement".into();
stamp_global_token_grant_owners(&mut policy);
assert_ne!(
first.token_grant_owner,
policy.network_policies["global"].endpoints[0].token_grant_owner
);
}
}
+3 -3
View File
@@ -115,9 +115,9 @@ pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"];
pub const OCSF_SCHEMA_VERSION_VALUES: &[&str] = &["", "1.1", "1.3"];
pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
// When true the sandbox writes OCSF v1.8.0 JSONL records to
// `/var/log/openshell-ocsf*.log` (daily rotation, 3 files) in addition
// to the human-readable shorthand log. Defaults to false (no JSONL written).
// When true the supervisor emits OCSF-JSON records to stderr and writes
// JSONL to `/var/log/openshell-ocsf*.log` when file logging is available.
// Defaults to false; shorthand output remains unchanged.
RegisteredSetting {
key: "ocsf_json_enabled",
kind: SettingValueKind::Bool,
+11 -8
View File
@@ -5701,18 +5701,21 @@ fn build_container_create_body_for_image(
driver_mounts::validate_mount_control_path(volume, BOUNDARY_MOUNT_PATH)
.map_err(Status::failed_precondition)?;
}
for mount in &driver_config.mounts {
let target = match mount {
let mount_targets = driver_config.mounts.iter().map(|mount| {
match mount {
DockerDriverMountConfig::Bind { target, .. }
| DockerDriverMountConfig::Volume { target, .. }
| DockerDriverMountConfig::Tmpfs { target, .. }
| DockerDriverMountConfig::Image { target, .. } => target,
};
driver_mounts::validate_workspace_mount_target(target, &workspace_root)
.map_err(Status::failed_precondition)?;
driver_mounts::validate_mount_control_path(target, BOUNDARY_MOUNT_PATH)
.map_err(Status::failed_precondition)?;
}
}
.as_str()
});
driver_mounts::validate_workspace_mount_targets(
mount_targets,
&workspace_root,
&[BOUNDARY_MOUNT_PATH],
)
.map_err(Status::failed_precondition)?;
let mut user_mounts = docker_driver_mounts(driver_config)?;
user_mounts.push(Mount {
target: Some(BOUNDARY_MOUNT_PATH.to_string()),
+20 -1
View File
@@ -44,7 +44,8 @@ only the channel bootstrap into the existing channel volume, preserving the
workspace. The workload starts before the supervisor so its user namespace exists
when the supervisor joins it; a stopped supervisor resolves that namespace again
on its next start. The driver creates the managed workspace volume owned by
the workload's final UID and GID, so the workload never starts as root.
the workload's final UID and GID, so the workload never starts as root. Custom
image workspaces have no workspace volume or upload.
The runtime must pass the sandbox's unprivileged enforcement probe, including
nested seccomp notification and Landlock. Unsupported runtime defaults fail
@@ -92,6 +93,24 @@ binary extraction path. `supervisor_image` supplies the dynamically linked
glibc `/openshell-supervisor` binary outside the workload. Image and request
environment belong to agent children, never the supervisor process.
## OCI working directory
OpenShell reads `WORKDIR` from the workload image. If it is unset, `/`, or
`/sandbox`, OpenShell uses its managed `/sandbox` workspace volume. A custom
path must be absolute and normalized, and cannot overlap `/proc`, `/sys`,
`/dev`, OpenShell-reserved paths, or the workload's private control and CA
mounts. Image volumes and driver mounts cannot cover it; mounts nested below it
remain valid.
A custom path stays in the image's container filesystem with its ownership and
permissions. The workload starts as the final non-root user, which must be able
to reach and write the directory. Agent commands use the path as their working
directory.
Custom workspace contents survive stop/start of the same workload container,
but are removed with that container. They do not live in a managed named volume
and are not copied to a replacement container.
## Lifecycle and readiness
Create builds both stopped containers and stages the private archives before
+29 -8
View File
@@ -181,12 +181,12 @@ pub struct ImageInspect {
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
#[serde(default, rename_all = "PascalCase")]
pub struct ImageConfig {
#[serde(default)]
pub user: String,
#[serde(default)]
pub env: Vec<String>,
pub working_dir: String,
pub volumes: Option<HashMap<String, Value>>,
}
/// A container summary returned by the list API.
@@ -1284,13 +1284,28 @@ mod tests {
}
}
#[test]
fn image_config_defaults_missing_oci_fields() {
let empty: ImageConfig = serde_json::from_str("{}").unwrap();
assert!(empty.user.is_empty());
assert!(empty.env.is_empty());
assert!(empty.working_dir.is_empty());
assert!(empty.volumes.is_none());
let partial: ImageConfig = serde_json::from_str(r#"{"User":"app:staff"}"#).unwrap();
assert_eq!(partial.user, "app:staff");
assert!(partial.env.is_empty());
assert!(partial.working_dir.is_empty());
assert!(partial.volumes.is_none());
}
#[tokio::test]
async fn inspect_image_reads_immutable_id_and_oci_user() {
async fn inspect_image_reads_immutable_id_and_oci_config() {
let (socket_path, request_log, handle) = spawn_podman_stub(
"inspect-image",
vec![StubResponse::new(
StatusCode::OK,
r#"{"Id":"sha256:immutable","Config":{"User":"app:staff"}}"#,
r#"{"Id":"sha256:immutable","Config":{"User":"app:staff","Env":["A=one"],"WorkingDir":"/workspace/project","Volumes":{"/workspace/project/cache":{}}}}"#,
)],
);
let client = PodmanClient::new(socket_path.clone());
@@ -1301,9 +1316,15 @@ mod tests {
.expect("image inspect should parse");
assert_eq!(image.id, "sha256:immutable");
assert_eq!(
image.config.as_ref().map(|config| config.user.as_str()),
Some("app:staff")
let config = image.config.expect("fixture image config");
assert_eq!(config.user, "app:staff");
assert_eq!(config.env, vec!["A=one"]);
assert_eq!(config.working_dir, "/workspace/project");
assert!(
config
.volumes
.as_ref()
.is_some_and(|volumes| volumes.contains_key("/workspace/project/cache"))
);
handle.await.expect("stub task should finish");
assert_eq!(
+280 -71
View File
@@ -3,6 +3,7 @@
//! Container spec construction for the Podman driver.
use crate::client::ImageInspect;
use crate::config::PodmanComputeConfig;
use openshell_core::ComputeDriverError;
use openshell_core::driver_mounts::SelinuxLabel;
@@ -49,6 +50,10 @@ const CONTAINER_PREFIX: &str = "openshell-";
/// Volume name prefix.
const VOLUME_PREFIX: &str = "openshell-sandbox-";
const PODMAN_WORKLOAD_CONTROL_PATHS: &[&str] = &[
"/.openshell",
openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_ROOT,
];
/// Secret name prefix for per-sandbox gateway JWTs.
const TOKEN_SECRET_PREFIX: &str = "openshell-token-";
@@ -209,6 +214,86 @@ pub fn short_id(id: &str) -> String {
id.chars().take(12).collect()
}
/// Immutable OCI image metadata normalized once for final launch.
#[derive(Debug, Clone)]
pub struct ResolvedPodmanImage {
/// Original reference for child metadata; launch always uses the immutable ID.
pub(crate) requested_image: String,
pub(crate) id: String,
pub(crate) oci_user: String,
pub(crate) environment: Vec<String>,
pub(crate) workspace_root: String,
}
impl ResolvedPodmanImage {
/// Resolve the image metadata and reject:
/// - a malformed or reserved working directory;
/// - for a custom working directory, an image volume with an invalid or
/// reserved target, or one covering the resolved workspace.
pub fn from_inspect(
requested_image: &str,
inspected: ImageInspect,
) -> Result<Self, ComputeDriverError> {
let config = inspected.config.unwrap_or_default();
let workspace_root = driver_mounts::resolve_oci_workspace_root(&config.working_dir)
.map_err(ComputeDriverError::Precondition)?;
for control_path in PODMAN_WORKLOAD_CONTROL_PATHS {
driver_mounts::validate_workspace_control_path(&workspace_root, control_path)
.map_err(ComputeDriverError::Precondition)?;
}
if workspace_root != driver_mounts::DEFAULT_WORKSPACE_ROOT
&& let Some(volumes) = config.volumes.as_ref()
{
for volume in volumes.keys() {
validate_container_mount_target(volume).map_err(|error| {
ComputeDriverError::Precondition(format!(
"invalid image-declared volume '{volume}': {error}"
))
})?;
driver_mounts::validate_workspace_mount_target(volume, &workspace_root).map_err(
|_| {
ComputeDriverError::Precondition(format!(
"image-declared volume '{volume}' masks OCI WorkingDir '{workspace_root}' before workspace validation"
))
},
)?;
}
}
Ok(Self {
requested_image: requested_image.to_string(),
id: inspected.id,
oci_user: config.user,
environment: config.env,
workspace_root,
})
}
/// Test fixture without inspected metadata; uses the managed workspace.
#[cfg(test)]
fn new(image: &str) -> Self {
Self::from_inspect(
image,
ImageInspect {
id: image.to_string(),
config: None,
},
)
.expect("fixture image metadata should be valid")
}
pub(crate) fn uses_managed_workspace(&self) -> bool {
self.workspace_root == driver_mounts::DEFAULT_WORKSPACE_ROOT
}
fn managed_workspace_volume(&self, sandbox_id: &str) -> Option<NamedVolume> {
self.uses_managed_workspace().then(|| NamedVolume {
name: volume_name(sandbox_id),
dest: self.workspace_root.clone(),
options: vec!["rw".into()],
})
}
}
// ---------------------------------------------------------------------------
// Typed container spec structs for the Podman libpod create API.
// ---------------------------------------------------------------------------
@@ -222,6 +307,11 @@ pub struct ContainerSpec {
volumes: Vec<NamedVolume>,
image_volumes: Vec<ImageVolume>,
hostname: String,
/// Start trusted runtime binaries from `/` instead of the image
/// `WORKDIR`, so Podman neither creates a missing workdir nor fails to
/// `chdir` before `OpenShell` validates it. Children use the resolved
/// workspace.
work_dir: String,
/// Overrides the image's ENTRYPOINT. In Podman's libpod API, `command`
/// only overrides CMD (appended as args to the entrypoint). We must set
/// `entrypoint` explicitly so the supervisor binary runs directly,
@@ -342,6 +432,20 @@ struct PodmanUserMounts {
mounts: Vec<Mount>,
}
impl PodmanUserMounts {
fn targets(&self) -> impl Iterator<Item = &str> {
self.mounts
.iter()
.map(|mount| mount.destination.as_str())
.chain(self.volumes.iter().map(|volume| volume.dest.as_str()))
.chain(
self.image_volumes
.iter()
.map(|volume| volume.destination.as_str()),
)
}
}
#[derive(Serialize)]
struct HealthConfig {
test: Vec<String>,
@@ -886,6 +990,11 @@ fn podman_driver_config(
Ok(config)
}
fn validate_container_mount_target(target: &str) -> Result<(), String> {
driver_mounts::validate_container_mount_target(target)?;
driver_mounts::validate_mount_control_path(target, "/.openshell")
}
fn validate_podman_driver_mounts(
mounts: &[PodmanDriverMountConfig],
enable_bind_mounts: bool,
@@ -935,8 +1044,7 @@ fn validate_podman_driver_mounts(
target
}
};
driver_mounts::validate_container_mount_target(target)?;
driver_mounts::validate_mount_control_path(target, "/.openshell")?;
validate_container_mount_target(target)?;
let normalized_target = driver_mounts::normalize_mount_target(target);
if !targets.insert(normalized_target.clone()) {
return Err(format!(
@@ -1060,29 +1168,25 @@ pub fn build_container_spec_with_token_and_gpu_devices(
gpu_device_ids: Option<&[String]>,
) -> Result<Value, ComputeDriverError> {
let image = resolve_image(sandbox, config);
let resolved_image = ResolvedPodmanImage::new(image);
build_container_spec_for_image(
sandbox,
config,
token_secret_name,
gpu_device_ids,
image,
image,
"",
&resolved_image,
None,
None,
)
}
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub fn build_container_spec_for_image(
sandbox: &DriverSandbox,
config: &PodmanComputeConfig,
token_secret_name: Option<&str>,
gpu_device_ids: Option<&[String]>,
requested_image: &str,
image_id: &str,
oci_user: &str,
image: &ResolvedPodmanImage,
supervisor_bin_path: Option<&Path>,
tls_secret_names: Option<&[String; 1]>,
) -> Result<Value, ComputeDriverError> {
@@ -1091,31 +1195,25 @@ pub fn build_container_spec_for_image(
config,
token_secret_name,
gpu_device_ids,
requested_image,
image_id,
oci_user,
image,
supervisor_bin_path,
tls_secret_names,
)?)
.map_err(|error| ComputeDriverError::Message(format!("encode Podman spec: {error}")))
}
#[allow(clippy::too_many_arguments)]
fn build_base_spec(
sandbox: &DriverSandbox,
config: &PodmanComputeConfig,
token_secret_name: Option<&str>,
gpu_device_ids: Option<&[String]>,
requested_image: &str,
image_id: &str,
oci_user: &str,
image: &ResolvedPodmanImage,
supervisor_bin_path: Option<&Path>,
tls_secret_names: Option<&[String; 1]>,
) -> Result<ContainerSpec, ComputeDriverError> {
let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id);
let vol = volume_name(&sandbox.id);
let env = build_env(sandbox, config, requested_image, oci_user)?;
let env = build_env(sandbox, config, &image.requested_image, &image.oci_user)?;
let mut labels = build_labels(sandbox);
labels.insert(
"openshell.ai/runtime-binary-source".into(),
@@ -1126,6 +1224,12 @@ fn build_base_spec(
let resource_limits = build_resource_limits(sandbox, config);
let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts)
.map_err(ComputeDriverError::InvalidArgument)?;
driver_mounts::validate_workspace_mount_targets(
user_mounts.targets(),
&image.workspace_root,
&["/.openshell"],
)
.map_err(ComputeDriverError::Precondition)?;
if sandbox
.spec
.as_ref()
@@ -1149,11 +1253,10 @@ fn build_base_spec(
let mut networks = BTreeMap::new();
networks.insert(config.network_name.clone(), NetworkAttachment {});
let mut volumes = vec![NamedVolume {
name: vol,
dest: "/sandbox".into(),
options: vec!["rw".into()],
}];
let mut volumes = image
.managed_workspace_volume(&sandbox.id)
.into_iter()
.collect::<Vec<_>>();
volumes.extend(user_mounts.volumes);
let mut image_volumes = if supervisor_bin_path.is_some() {
@@ -1166,15 +1269,12 @@ fn build_base_spec(
}]
};
image_volumes.extend(user_mounts.image_volumes);
let mut command = vec![
"--workdir".to_string(),
driver_mounts::DEFAULT_WORKSPACE_ROOT.to_string(),
];
let mut command = vec!["--workdir".to_string(), image.workspace_root.clone()];
command.extend(upstream_proxy_cli_args(config));
let container_spec = ContainerSpec {
name,
image: image_id.to_string(),
image: image.id.clone(),
labels,
env,
volumes,
@@ -1185,6 +1285,10 @@ fn build_base_spec(
// /openshell-sandbox, so it appears at /opt/openshell/bin/openshell-sandbox.
image_volumes,
hostname: format!("sandbox-{}", sandbox.name),
// This is the runtime's cwd, not the agent's: Podman must not create
// or enter the image workspace before OpenShell validates it as the
// final identity. Agent commands receive that path via --workdir.
work_dir: "/".into(),
// Override the image's ENTRYPOINT so the supervisor binary runs
// directly. Workload images can set
// ENTRYPOINT ["/bin/bash"], and Podman's `command` field only
@@ -1192,10 +1296,9 @@ fn build_base_spec(
// Without this, the container would run the entrypoint binary with
// the supervisor path as an argument instead of executing it directly.
entrypoint: vec![SUPERVISOR_BINARY_PATH.into()],
// Keep Podman's existing /sandbox workspace contract explicit while
// the supervisor supports driver-selected workdirs. Operator-owned
// corporate proxy flags follow it; the workload command comes from
// the reserved environment variable.
// Pass the resolved workspace to the logical supervisor. Operator-owned
// corporate proxy flags follow it; the workload command comes from the
// reserved environment variable.
command,
// The paired builder supplies the immutable non-root identity.
user: String::new(),
@@ -1410,10 +1513,7 @@ pub struct IsolationSpecInput<'a> {
pub token_secret: Option<&'a str>,
pub resolver_secret: &'a str,
pub gpu_devices: Option<&'a [String]>,
pub requested_image: &'a str,
pub image_id: &'a str,
pub image_user: &'a str,
pub image_env: &'a [String],
pub image: &'a ResolvedPodmanImage,
pub supervisor_bin: Option<&'a Path>,
pub tls_secrets: Option<&'a [String; 1]>,
pub identity: &'a openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
@@ -1447,9 +1547,7 @@ pub fn build_isolation_specs(
input.config,
input.token_secret,
input.gpu_devices,
input.requested_image,
input.image_id,
input.image_user,
input.image,
input.supervisor_bin,
input.tls_secrets,
)
@@ -1464,7 +1562,8 @@ pub fn build_isolation_specs(
.insert(crate::isolation::LABEL_ROLE.into(), "sandbox".into());
workload.env = BTreeMap::new();
workload.unsetenv = input
.image_env
.image
.environment
.iter()
.filter_map(|entry| entry.split_once('=').map(|(key, _)| key.to_string()))
.collect();
@@ -1732,11 +1831,32 @@ fn parse_memory_to_bytes(quantity: &str) -> Option<u64> {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::ImageConfig;
use openshell_core::proto::compute::v1::{GpuResourceRequirements, ResourceRequirements};
static ENV_LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
fn resolved_image(
requested_image: &str,
id: &str,
user: &str,
working_dir: &str,
) -> ResolvedPodmanImage {
ResolvedPodmanImage::from_inspect(
requested_image,
ImageInspect {
id: id.to_string(),
config: Some(ImageConfig {
user: user.to_string(),
working_dir: working_dir.to_string(),
..Default::default()
}),
},
)
.expect("fixture image metadata should be valid")
}
#[test]
fn isolated_pair_keeps_privileges_network_and_secrets_out_of_workload() {
let sandbox = DriverSandbox {
@@ -1757,7 +1877,8 @@ mod tests {
"sha256:image".into(),
)
.unwrap();
let env = vec![
let mut image = resolved_image("image:latest", "sha256:image", "1000:1001", "");
image.environment = vec![
"LD_PRELOAD=/hostile.so".into(),
"HTTP_PROXY=http://bypass".into(),
];
@@ -1767,10 +1888,7 @@ mod tests {
token_secret: Some("jwt"),
resolver_secret: "resolver",
gpu_devices: None,
requested_image: "image:latest",
image_id: "sha256:image",
image_user: "1000:1001",
image_env: &env,
image: &image,
supervisor_bin: None,
tls_secrets: None,
identity: &identity,
@@ -1823,10 +1941,7 @@ mod tests {
token_secret: Some("jwt"),
resolver_secret: "resolver",
gpu_devices: None,
requested_image: "image:latest",
image_id: "sha256:image",
image_user: "",
image_env: &env,
image: &ResolvedPodmanImage::new("sha256:image"),
supervisor_bin: None,
tls_secrets: None,
identity: &default_identity,
@@ -1916,6 +2031,58 @@ mod tests {
);
}
#[test]
fn resolved_image_creates_workspace_volume_only_for_managed_fallback() {
let default = ResolvedPodmanImage::new("image:latest");
assert_eq!(default.requested_image, "image:latest");
assert_eq!(default.id, "image:latest");
assert_eq!(
default.workspace_root,
driver_mounts::DEFAULT_WORKSPACE_ROOT
);
for workdir in ["", "/", "/sandbox", "/sandbox/", "/workspace/project"] {
let image = resolved_image("image:latest", "sha256:immutable", "1000:1000", workdir);
let volume = image.managed_workspace_volume("sandbox-id");
assert_eq!(image.requested_image, "image:latest");
assert_eq!(image.id, "sha256:immutable");
if workdir == "/workspace/project" {
assert!(volume.is_none());
} else {
let volume = volume.expect("managed fallback must have a workspace volume");
assert_eq!(volume.name, volume_name("sandbox-id"));
assert_eq!(volume.dest, "/sandbox");
assert_eq!(volume.options, vec!["rw"]);
}
}
}
#[test]
fn resolved_image_rejects_volumes_covering_working_dir() {
let inspect = |volume: &str| ImageInspect {
id: "sha256:image".into(),
config: Some(ImageConfig {
working_dir: "/workspace/project".into(),
volumes: Some(std::collections::HashMap::from([(
volume.into(),
Value::Null,
)])),
..Default::default()
}),
};
assert!(ResolvedPodmanImage::from_inspect("image:latest", inspect("/workspace")).is_err());
let image =
ResolvedPodmanImage::from_inspect("image:latest", inspect("/workspace/project/cache"))
.expect("image volumes nested below the workspace remain valid");
assert_eq!(image.workspace_root, "/workspace/project");
let mut fallback = inspect("/etc/openshell");
fallback.config.as_mut().unwrap().working_dir = "/sandbox".into();
ResolvedPodmanImage::from_inspect("image:latest", fallback)
.expect("existing /sandbox images keep their image-volume behavior");
}
fn json_struct(value: Value) -> prost_types::Struct {
let Value::Object(object) = value else {
panic!("expected JSON object");
@@ -2025,14 +2192,18 @@ mod tests {
spec.environment.insert(key.to_string(), value.to_string());
}
let image = resolved_image(
"registry.example/app:latest",
"sha256:immutable",
"app:staff",
"/workspace/project",
);
let container = build_container_spec_for_image(
&sandbox,
&test_config(),
None,
None,
"registry.example/app:latest",
"sha256:immutable",
"app:staff",
&image,
None,
None,
)
@@ -2048,6 +2219,12 @@ mod tests {
assert_eq!(container["image_pull_policy"].as_str(), Some("never"));
assert_eq!(container["dns_search"], serde_json::json!([]));
assert_eq!(container["dns_option"], serde_json::json!([]));
assert_eq!(container["work_dir"].as_str(), Some("/"));
assert_eq!(
container["command"],
serde_json::json!(["--workdir", "/workspace/project"])
);
assert!(container["volumes"].as_array().is_some_and(Vec::is_empty));
assert_eq!(
container["env"][openshell_core::sandbox_env::OCI_IMAGE_USER].as_str(),
Some("app:staff")
@@ -2060,10 +2237,6 @@ mod tests {
container["env"][openshell_core::sandbox_env::SANDBOX_GID].as_str(),
Some("")
);
assert_eq!(
container["command"],
serde_json::json!(["--workdir", "/sandbox"])
);
}
#[test]
@@ -2306,18 +2479,8 @@ mod tests {
fn container_spec_defaults_drop_capabilities_and_keep_runtime_seccomp() {
let sandbox = test_sandbox("test-id", "test-name");
let config = test_config();
let spec = build_base_spec(
&sandbox,
&config,
None,
None,
"image",
"sha256:image",
"",
None,
None,
)
.unwrap();
let image = ResolvedPodmanImage::new("sha256:image");
let spec = build_base_spec(&sandbox, &config, None, None, &image, None, None).unwrap();
assert_eq!(spec.cap_drop, vec!["ALL"]);
assert!(spec.cap_add.is_empty());
assert!(spec.seccomp_profile_path.is_empty());
@@ -3048,6 +3211,53 @@ mod tests {
);
}
#[test]
fn resolved_workspace_checks_all_driver_mount_types() {
let image = resolved_image(
"image:latest",
"sha256:immutable",
"1000:1000",
"/workspace/project",
);
let mut config = test_config();
config.enable_bind_mounts = true;
for mut mount in [
serde_json::json!({"type": "bind", "source": "/host/workspace"}),
serde_json::json!({"type": "volume", "source": "data"}),
serde_json::json!({"type": "tmpfs"}),
serde_json::json!({"type": "image", "source": "image:mounted"}),
] {
for target in [
"/workspace",
"/workspace/project",
"/workspace/project/cache",
] {
mount["target"] = target.into();
let mut sandbox = test_sandbox("test-id", "test-name");
sandbox
.spec
.get_or_insert_default()
.template
.get_or_insert_default()
.driver_config =
Some(json_struct(serde_json::json!({"mounts": [mount.clone()]})));
let result = build_container_spec_for_image(
&sandbox, &config, None, None, &image, None, None,
);
if target == "/workspace/project/cache" {
result.expect("mounts nested below the workspace remain valid");
} else {
assert!(
result
.unwrap_err()
.to_string()
.contains("reserved for the OpenShell workspace")
);
}
}
}
}
#[test]
fn driver_config_rejects_bind_mounts_unless_enabled() {
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
@@ -3713,14 +3923,13 @@ mod tests {
let sandbox = test_sandbox("bind-sv-id", "bind-sv-name");
let config = test_config();
let image = resolve_image(&sandbox, &config);
let resolved = ResolvedPodmanImage::new(image);
let spec = build_container_spec_for_image(
&sandbox,
&config,
None,
None,
image,
image,
"",
&resolved,
Some(Path::new("/host/cache/openshell-sandbox")),
None,
)
+25 -26
View File
@@ -942,7 +942,7 @@ impl PodmanComputeDriver {
"Creating sandbox container"
);
let (image, immutable_image_id, image_user, image_env) = async {
let resolved_image = async {
let phase_status = openshell_otel::ErrorStatusGuard::current();
let result = async {
// The sandbox runtime is shipped in a standalone OCI image.
@@ -1000,10 +1000,8 @@ impl PodmanComputeDriver {
"podman image '{image}' inspection did not return an immutable image ID"
)));
}
let image_user = inspected_image
.config
.as_ref()
.map_or_else(String::new, |config| config.user.clone());
let resolved_image =
container::ResolvedPodmanImage::from_inspect(image, inspected_image)?;
for mount_image in container::podman_driver_image_mount_sources(
sandbox,
@@ -1018,8 +1016,7 @@ impl PodmanComputeDriver {
.map_err(ComputeDriverError::from)?;
}
let image_env = inspected_image.config.as_ref().map_or_else(Vec::new, |config| config.env.clone());
Ok((image.to_string(), inspected_image.id, image_user, image_env))
Ok(resolved_image)
}
.await;
phase_status.finish(result)
@@ -1030,6 +1027,7 @@ impl PodmanComputeDriver {
otel.status_code = tracing::field::Empty,
))
.await?;
let managed_workspace = resolved_image.uses_managed_workspace();
// Fail closed on a missing/unreadable corporate proxy CA bundle before
// creating any resources, so the operator gets a clear error
@@ -1043,7 +1041,7 @@ impl PodmanComputeDriver {
.map_err(ComputeDriverError::from)?;
let identity = self
.resolve_workload_identity(sandbox, &immutable_image_id, &image_user)
.resolve_workload_identity(sandbox, &resolved_image.id, &resolved_image.oci_user)
.await?;
let channel_volume = crate::isolation::channel_volume_name(&sandbox.id);
let mut runtime_config = self.config.clone();
@@ -1068,19 +1066,21 @@ impl PodmanComputeDriver {
));
}
// Create the workspace volume and per-sandbox runtime files.
// Create the managed workspace volume, if needed, and runtime files.
let (resolver_secret_name, token_secret_name, proxy_auth_secret_name) = async {
let phase_status = openshell_otel::ErrorStatusGuard::current();
let result = async {
self.client
.create_owned_volume(
&vol_name,
&sandbox.id,
&sandbox.workspace,
Some((identity.uid, identity.gid)),
)
.await
.map_err(ComputeDriverError::from)?;
if managed_workspace {
self.client
.create_owned_volume(
&vol_name,
&sandbox.id,
&sandbox.workspace,
Some((identity.uid, identity.gid)),
)
.await
.map_err(ComputeDriverError::from)?;
}
let resolver_secret_name =
match create_sandbox_resolver_secret(&self.client, &sandbox.id).await {
Ok(name) => name,
@@ -1199,10 +1199,7 @@ impl PodmanComputeDriver {
token_secret: token_secret_name.as_deref(),
resolver_secret: &resolver_secret_name,
gpu_devices: gpu_devices.as_deref(),
requested_image: &image,
image_id: &immutable_image_id,
image_user: &image_user,
image_env: &image_env,
image: &resolved_image,
supervisor_bin: supervisor_bin_path.as_deref(),
tls_secrets: tls_secret_names.as_ref(),
identity: &identity,
@@ -1227,7 +1224,7 @@ impl PodmanComputeDriver {
created_workload = Some(workload_id.clone());
self.client.verify_isolation_fence(&workload_id).await?;
self.admit_container_resources(&workload_id).await?;
let child_env = podman_child_environment(sandbox, &image_env);
let child_env = podman_child_environment(sandbox, &resolved_image.environment);
let launch_authentication = sandbox
.spec
.as_ref()
@@ -1263,9 +1260,11 @@ impl PodmanComputeDriver {
archives.channel,
)
.await?;
self.client
.copy_to_container(&workload_id, "/sandbox", archives.workspace)
.await?;
if managed_workspace {
self.client
.copy_to_container(&workload_id, "/sandbox", archives.workspace)
.await?;
}
let supervisor_id = self
.client
.create_typed_container(&specs.supervisor)
@@ -15,7 +15,7 @@
use std::io;
use std::os::fd::AsFd;
use socket2::{Domain, SockFilter, SockRef, Socket, Type};
use socket2::{Domain, SockRef, Socket, Type};
const LOOPBACK_DEVICE: &[u8] = b"lo";
@@ -38,50 +38,6 @@ pub fn bound_device(fd: impl AsFd) -> io::Result<Option<Vec<u8>>> {
SockRef::from(&fd).device()
}
/// Drop TCP/UDP ingress that arrives on the loopback interface.
///
/// Attach this to a trusted listener whose legitimate clients are never in the
/// same network namespace. Matching the ingress interface rather than the
/// source address also rejects connections to the host's own non-loopback
/// address, which the kernel delivers through loopback. The filter is not
/// locked: the listener descriptor never leaves the trusted sandbox process,
/// which marks every descriptor above stdio close-on-exec before running
/// workload code.
///
/// # Errors
///
/// Returns the kernel error when the interface index cannot be resolved or
/// the filter cannot be attached.
pub fn reject_loopback_ingress(fd: impl AsFd) -> io::Result<()> {
let index = rustix::net::netdevice::name_to_index(&fd, "lo")?;
reject_ingress_interface(fd, index)
}
fn reject_ingress_interface(fd: impl AsFd, index: u32) -> io::Result<()> {
// Ancillary loads use the documented negative offset encoding.
let ifindex_offset = (libc::SKF_AD_OFF + libc::SKF_AD_IFINDEX).cast_unsigned();
let program = [
filter(
libc::BPF_LD | libc::BPF_W | libc::BPF_ABS,
0,
0,
ifindex_offset,
),
filter(libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K, 0, 1, index),
filter(libc::BPF_RET | libc::BPF_K, 0, 0, 0),
filter(libc::BPF_RET | libc::BPF_K, 0, 0, u32::MAX),
];
SockRef::from(&fd).attach_filter(&program)
}
#[allow(
clippy::cast_possible_truncation,
reason = "classic BPF opcodes are 16-bit by definition"
)]
const fn filter(code: u32, jt: u8, jf: u8, k: u32) -> SockFilter {
SockFilter::new(code as u16, jt, jf, k)
}
/// Actively prove loopback confinement under the current runtime profile.
///
/// For each supported workload socket type this installs the binding and
@@ -178,8 +134,7 @@ fn probe_error(context: &str, error: &io::Error) -> io::Error {
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read as _, Write as _};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
use std::time::Duration;
fn new_socket(domain: Domain, kind: Type) -> Socket {
@@ -212,38 +167,6 @@ mod tests {
assert_eq!(bound_device(&socket).unwrap().as_deref(), Some(&b"lo"[..]));
}
fn connect_with_timeout(address: SocketAddr) -> io::Result<TcpStream> {
TcpStream::connect_timeout(&address, Duration::from_millis(300))
}
#[test]
fn loopback_ingress_filter_rejects_loopback_connections() {
let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
reject_loopback_ingress(&listener).unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
// Dropped SYNs never complete the handshake.
assert!(connect_with_timeout(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).is_err());
assert_eq!(
listener.accept().unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
}
#[test]
fn ingress_filter_admits_other_interfaces() {
// Positive control: the same program keyed to an absent interface
// index must leave loopback traffic untouched.
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
reject_ingress_interface(&listener, u32::MAX).unwrap();
let mut client = connect_with_timeout(listener.local_addr().unwrap()).unwrap();
let (mut accepted, _) = listener.accept().unwrap();
client.write_all(b"ping").unwrap();
let mut buffer = [0_u8; 4];
accepted.read_exact(&mut buffer).unwrap();
assert_eq!(&buffer, b"ping");
}
/// Return a local non-loopback address, if this namespace has one.
fn local_non_loopback_address() -> Option<Ipv4Addr> {
let probe = std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?;
@@ -1,13 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Tracing layer that writes OCSF JSONL to a writer.
//! Tracing layer that writes OCSF JSONL or marked console records to a writer.
use std::io::Write;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::Utc;
use tracing::Subscriber;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
@@ -33,6 +34,7 @@ pub struct OcsfJsonlLayer<W: Write + Send + 'static> {
writer: Mutex<W>,
enabled: Option<Arc<AtomicBool>>,
target_version: Option<Arc<Mutex<String>>>,
console_format: bool,
}
impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
@@ -43,6 +45,7 @@ impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
writer: Mutex::new(writer),
enabled: None,
target_version: None,
console_format: false,
}
}
@@ -65,6 +68,16 @@ impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
self.target_version = Some(version);
self
}
/// Prefix each compact JSON record with a UTC timestamp and `OCSF-JSON`.
///
/// Console collectors can match the marker and decode the remainder as
/// JSON. File sinks retain plain JSONL unless this option is selected.
#[must_use]
pub fn with_console_format(mut self) -> Self {
self.console_format = true;
self
}
}
impl<S, W> Layer<S> for OcsfJsonlLayer<W>
@@ -108,6 +121,13 @@ where
Err(_) => return,
}
};
let line = if self.console_format {
let ts = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ");
format!("{ts} OCSF-JSON {line}")
} else {
line
};
// Submit the entire record in one write, including its newline.
let _ = w.write_all(line.as_bytes());
}
}
@@ -60,7 +60,7 @@ where
if let Some(ocsf_event) = clone_current_event() {
let line = ocsf_event.format_shorthand();
if let Ok(mut w) = self.writer.lock() {
let _ = writeln!(w, "{ts} OCSF {line}");
let _ = write_record(&mut *w, format!("{ts} OCSF {line}"));
}
}
} else if self.include_non_ocsf {
@@ -71,12 +71,22 @@ where
let mut message = String::new();
event.record(&mut MessageVisitor(&mut message));
if let Ok(mut w) = self.writer.lock() {
let _ = writeln!(w, "{ts} {level} {target}: {message}");
let _ = write_record(&mut *w, format!("{ts} {level} {target}: {message}"));
}
}
}
}
/// Preserve one physical line for the entire rendered record, including its
/// target and any event fields. JSON serialization handles its own escaping.
fn write_record(writer: &mut impl Write, mut line: String) -> std::io::Result<()> {
if line.contains(['\r', '\n']) {
line = line.replace('\r', "\\r").replace('\n', "\\n");
}
line.push('\n');
writer.write_all(line.as_bytes())
}
/// Simple visitor that extracts the message field from tracing events.
struct MessageVisitor<'a>(&'a mut String);
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Collector framing, live settings, and whole-line console writes.
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use openshell_ocsf::{
EventContext, NetworkActivityBuilder, OcsfJsonlLayer, OcsfShorthandLayer, ocsf_emit,
};
use tracing_subscriber::layer::SubscriberExt;
/// Capture individual writes to verify queue entries are whole lines.
#[derive(Clone, Default)]
struct Records(Arc<Mutex<Vec<Vec<u8>>>>);
impl Write for Records {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().push(bytes.to_vec());
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn payload(record: &[u8]) -> serde_json::Value {
let line = std::str::from_utf8(record).unwrap();
assert_eq!(line.lines().count(), 1);
assert!(line.ends_with('\n'));
assert!(!line.contains('\x1b'));
let (timestamp, json) = line.trim_end().split_once(" OCSF-JSON ").unwrap();
chrono::DateTime::parse_from_rfc3339(timestamp).unwrap();
serde_json::from_str(json).unwrap()
}
fn context() -> EventContext {
EventContext {
sandbox_id: "sb-console".to_string(),
sandbox_name: "console-test".to_string(),
container_image: "test-image".to_string(),
hostname: "test-host".to_string(),
product_version: "test".to_string(),
proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
proxy_port: 3128,
origin: openshell_ocsf::EventOrigin::Supervisor,
}
}
#[test]
fn console_json_toggles_and_downgrades_without_changing_event_identity() {
let records = Records::default();
let enabled = Arc::new(AtomicBool::new(false));
let version = Arc::new(Mutex::new(String::new()));
let subscriber = tracing_subscriber::registry().with(
OcsfJsonlLayer::new(records.clone())
.with_console_format()
.with_enabled_flag(enabled.clone())
.with_target_version(version.clone()),
);
let event = NetworkActivityBuilder::new(&context())
.dst_endpoint(openshell_ocsf::Endpoint::from_domain("example.com", 443))
.message("first line\nsecond line")
.build();
let expected = event.to_json().unwrap();
tracing::subscriber::with_default(subscriber, || {
ocsf_emit!(event.clone());
tracing::warn!("diagnostics are not JSON audit events");
enabled.store(true, Ordering::Relaxed);
ocsf_emit!(event.clone());
*version.lock().unwrap() = "1.3".to_string();
ocsf_emit!(event.clone());
enabled.store(false, Ordering::Relaxed);
ocsf_emit!(event);
});
let records = records.0.lock().unwrap();
assert_eq!(records.len(), 2);
assert_eq!(payload(&records[0]), expected);
let downgraded = payload(&records[1]);
assert_eq!(downgraded["metadata"]["version"], "1.3");
assert_eq!(downgraded["metadata"]["uid"], expected["metadata"]["uid"]);
assert_eq!(downgraded["message"], expected["message"]);
}
#[test]
fn concurrent_console_formats_submit_complete_records() {
let records = Records::default();
let subscriber = tracing_subscriber::registry()
.with(OcsfShorthandLayer::new(records.clone()))
.with(OcsfJsonlLayer::new(records.clone()).with_console_format());
let dispatch = tracing::Dispatch::new(subscriber);
std::thread::scope(|scope| {
for worker in 0..8 {
let dispatch = dispatch.clone();
scope.spawn(move || {
tracing::dispatcher::with_default(&dispatch, || {
for index in 0..50 {
tracing::info!("diagnostic {worker}:{index}");
ocsf_emit!(
NetworkActivityBuilder::new(&context())
.dst_endpoint(openshell_ocsf::Endpoint::from_domain(
"example.com",
443
))
.message(format!("connection {worker}:{index}"))
.build()
);
}
});
});
}
});
let records = records.0.lock().unwrap();
assert_eq!(records.len(), 8 * 50 * 3);
let mut json_count = 0;
for record in records.iter() {
let line = std::str::from_utf8(record).unwrap();
assert_eq!(line.lines().count(), 1);
assert!(line.ends_with('\n'));
if line.contains(" OCSF-JSON ") {
payload(record);
json_count += 1;
} else {
assert!(line.contains(" OCSF ") || line.contains(" INFO "));
}
}
assert_eq!(json_count, 8 * 50);
}
#[test]
fn non_json_console_records_preserve_physical_line_boundaries() {
let records = Records::default();
let subscriber = tracing_subscriber::registry()
.with(OcsfShorthandLayer::new(records.clone()))
.with(OcsfJsonlLayer::new(records.clone()).with_console_format());
let message = "first\r\nsecond\nthird\rfourth";
let event = openshell_ocsf::ConfigStateChangeBuilder::new(&context())
.message(message)
.build();
tracing::subscriber::with_default(subscriber, || {
tracing::info!(target: "console\r\ncontinued", message = message);
ocsf_emit!(event);
});
let records = records.0.lock().unwrap();
assert_eq!(records.len(), 3);
for record in records.iter() {
assert_eq!(
record.iter().position(|byte| *byte == b'\n'),
Some(record.len() - 1)
);
assert!(!record.contains(&b'\r'));
assert_eq!(record.last(), Some(&b'\n'));
let line = std::str::from_utf8(record).unwrap();
if line.contains(" OCSF-JSON ") {
assert_eq!(payload(record)["message"], message);
} else {
assert!(line.contains("first\\r\\nsecond\\nthird\\rfourth"));
}
}
}
@@ -14,6 +14,7 @@ repository.workspace = true
miette = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_path_to_error = { workspace = true }
serde_yml = { workspace = true }
thiserror = { workspace = true }
+55 -5
View File
@@ -591,9 +591,17 @@ pub fn parse_policy_with_limits(source: &str, limits: ParseLimits) -> Result<Pol
if let Some(unknown_field) = find_unknown_field(&value) {
miette::bail!("unknown field '{}' in authored policy", unknown_field.path);
}
let policy: PolicyDocument = serde_yml::from_value(&value)
.into_diagnostic()
.wrap_err("failed to decode sandbox policy fields")?;
let policy: PolicyDocument =
serde_path_to_error::deserialize(serde_yml::Deserializer::new(&value))
.map_err(|error| {
let path = bound_path(error.path().to_string());
if path == "." {
miette::miette!("{}", error.inner())
} else {
miette::miette!("{path}: {}", error.inner())
}
})
.wrap_err("failed to decode sandbox policy fields")?;
validate_policy(&policy)?;
Ok(policy)
}
@@ -937,11 +945,15 @@ fn sequence(value: Option<&serde_yml::Value>) -> &[serde_yml::Value] {
}
fn join(parent: &str, child: &str) -> String {
let mut path = if parent.is_empty() {
bound_path(if parent.is_empty() {
child.to_owned()
} else {
format!("{parent}.{child}")
};
})
}
// Cap diagnostic paths so oversized authored keys cannot bloat errors.
fn bound_path(mut path: String) -> String {
if path.len() > MAX_UNKNOWN_FIELD_PATH_BYTES {
let mut end = MAX_UNKNOWN_FIELD_PATH_BYTES - 3;
while !path.is_char_boundary(end) {
@@ -1279,6 +1291,44 @@ mod tests {
}
}
fn decode_error_chain(source: &str) -> Vec<String> {
let error = parse_policy(source).expect_err("type mismatch must fail closed");
error.chain().map(ToString::to_string).collect()
}
#[test]
fn type_errors_name_the_offending_field() {
let source = "version: 1\nnetwork_policies:\n github_api:\n endpoints:\n - host: api.github.com\n port: \"443\"\n";
assert_eq!(
decode_error_chain(source),
[
"failed to decode sandbox policy fields",
"network_policies.github_api.endpoints[0].port: type mismatch: expected unsigned integer, found string",
]
);
}
#[test]
fn type_errors_without_a_path_keep_their_message() {
let chain = decode_error_chain("- version: 1\n");
assert_eq!(chain[0], "failed to decode sandbox policy fields");
assert!(
!chain[1].starts_with(".: "),
"unexpected prefix in {chain:?}"
);
}
#[test]
fn bounds_type_error_paths_under_long_keys() {
let policy_name = "é".repeat(2_000);
let source = format!(
"version: 1\nnetwork_policies:\n {policy_name}:\n endpoints:\n - host: example.com\n port: \"443\"\n"
);
let chain = decode_error_chain(&source);
assert!(chain[1].contains("...: type mismatch"));
assert!(chain[1].len() <= MAX_UNKNOWN_FIELD_PATH_BYTES + 100);
}
#[test]
fn accepts_open_user_data_maps() {
let source = r#"
+1
View File
@@ -549,6 +549,7 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
// Provider credential provenance is derived by the
// gateway and cannot be authored in policy YAML.
provider_credentialed: false,
token_grant_owner: String::new(),
// Advisor provenance is internal runtime state, not
// a user-authored policy schema field.
advisor_proposed: false,
+12 -2
View File
@@ -54,10 +54,11 @@ pub fn canonicalize_advisor_add_rule(
.cloned()
.map(|mut endpoint| {
// Provenance does not change the endpoint contract. The gateway
// derives the credential marker, and the advisor marker records
// where a persisted endpoint came from.
// derives credential markers and token owners; the advisor marker
// records where a persisted endpoint came from.
endpoint.provider_credentialed = false;
endpoint.advisor_proposed = false;
endpoint.token_grant_owner.clear();
// A denial observes one binary-to-port authorization. Preserve the
// existing inspection contract, but never copy sibling ports from
// a multi-port endpoint into the proposal.
@@ -93,6 +94,7 @@ pub fn canonicalize_advisor_add_rule(
let mut normalized = endpoint.clone();
normalized.provider_credentialed = false;
normalized.advisor_proposed = false;
normalized.token_grant_owner.clear();
normalize_endpoint(&mut normalized);
normalized == contract
}) && incoming_rule
@@ -114,6 +116,7 @@ pub fn canonicalize_advisor_add_rule(
let mut normalized = (*endpoint).clone();
normalized.provider_credentialed = false;
normalized.advisor_proposed = false;
normalized.token_grant_owner.clear();
normalize_endpoint(&mut normalized);
normalized == contract
})
@@ -2611,6 +2614,7 @@ mod tests {
fn canonicalize_advisor_preserves_existing_advisor_endpoint_provenance() {
let mut advisor_endpoint = endpoint("index.crates.io", 443);
advisor_endpoint.advisor_proposed = true;
advisor_endpoint.token_grant_owner = "copied-advisor-owner".to_string();
let mut base = SandboxPolicy::default();
base.network_policies.insert(
"advisor_index".to_string(),
@@ -2637,6 +2641,7 @@ mod tests {
assert_eq!(rule_name, "advisor_index");
assert!(canonical.endpoints[0].advisor_proposed);
assert!(canonical.endpoints[0].token_grant_owner.is_empty());
}
#[test]
@@ -2647,6 +2652,7 @@ mod tests {
provider_endpoint.enforcement = NetworkEnforcementMode::Enforce as i32;
provider_endpoint.access = NetworkAccessPreset::ReadOnly as i32;
provider_endpoint.provider_credentialed = true;
provider_endpoint.token_grant_owner = "derived-provider-owner".to_string();
let mut effective = SandboxPolicy::default();
effective.network_policies.insert(
"_provider_example".to_string(),
@@ -2678,6 +2684,7 @@ mod tests {
);
assert!(!canonical.endpoints[0].provider_credentialed);
assert!(canonical.endpoints[0].advisor_proposed);
assert!(canonical.endpoints[0].token_grant_owner.is_empty());
assert_eq!(
effective.network_policies["_provider_example"].endpoints[0],
provider_endpoint
@@ -2691,10 +2698,12 @@ mod tests {
provider_endpoint.enforcement = NetworkEnforcementMode::Enforce as i32;
provider_endpoint.access = NetworkAccessPreset::ReadOnly as i32;
provider_endpoint.provider_credentialed = true;
provider_endpoint.token_grant_owner = "derived-provider-owner".to_string();
let mut advisor_endpoint = provider_endpoint.clone();
advisor_endpoint.provider_credentialed = false;
advisor_endpoint.advisor_proposed = true;
advisor_endpoint.token_grant_owner = "copied-advisor-owner".to_string();
let mut base = SandboxPolicy::default();
base.network_policies.insert(
@@ -2738,6 +2747,7 @@ mod tests {
NetworkAccessPreset::ReadOnly as i32
);
assert!(canonical.endpoints[0].advisor_proposed);
assert!(canonical.endpoints[0].token_grant_owner.is_empty());
}
#[test]
+208
View File
@@ -1003,6 +1003,7 @@ impl ProviderTypeProfile {
refresh: credential.refresh.as_ref().map(credential_refresh_to_proto),
path_template: credential.path_template.clone(),
token_grant: credential.token_grant.as_ref().map(token_grant_to_proto),
token_grant_owners: Vec::new(),
})
.collect(),
files: self
@@ -1666,6 +1667,7 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint {
request_body_credential_rewrite: endpoint.request_body_credential_rewrite,
allow_uninspected_credentials: endpoint.allow_uninspected_credentials,
provider_credentialed: false,
token_grant_owner: String::new(),
advisor_proposed: false,
persisted_queries: endpoint.persisted_queries.clone(),
graphql_persisted_queries: endpoint
@@ -2214,6 +2216,15 @@ pub fn validate_profile_set(
));
}
}
if credential.name.chars().any(char::is_control) {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
profile_id,
"credentials.name",
"credential name must not contain control characters",
));
continue;
}
let credential_name = credential.name.trim();
if credential_name.is_empty() {
diagnostics.push(ProfileValidationDiagnostic::error(
@@ -2690,6 +2701,10 @@ pub fn validate_profile_set(
}
}
let has_token_grant = profile
.credentials
.iter()
.any(|credential| credential.token_grant.is_some());
for (index, endpoint) in profile.endpoints.iter().enumerate() {
if !endpoint_is_valid(endpoint) {
diagnostics.push(ProfileValidationDiagnostic::error(
@@ -3050,6 +3065,35 @@ pub fn validate_profile_set(
}
}
if has_token_grant {
for (field, value) in [("host", &endpoint.host), ("path", &endpoint.path)] {
if value.chars().any(char::is_control) {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
profile_id,
format!("endpoints[{index}].{field}"),
"token_grant endpoint selectors must not contain control characters",
));
}
}
if endpoint.protocol.trim().eq_ignore_ascii_case("sql") {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
profile_id,
format!("endpoints[{index}].protocol"),
"token_grant credentials require HTTP header injection, which protocol sql does not support",
));
}
if endpoint.tls.trim().eq_ignore_ascii_case("skip") {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
profile_id,
format!("endpoints[{index}].tls"),
"token_grant credentials require HTTP header injection, which tls: skip bypasses; remove tls: skip",
));
}
}
if profile.has_credentialed_endpoints()
&& !endpoint.allow_uninspected_credentials
&& (endpoint.protocol.trim().is_empty()
@@ -3351,6 +3395,19 @@ fn validate_token_grant_audience_overrides(
let mut diagnostics = Vec::new();
let mut bindings: Vec<TokenGrantOverrideBinding> = Vec::new();
for (override_index, override_config) in token_grant.audience_overrides.iter().enumerate() {
for (field, value) in [
("host", &override_config.host),
("path", &override_config.path),
] {
if value.chars().any(char::is_control) {
diagnostics.push(ProfileValidationDiagnostic::error(
source,
profile_id,
format!("credentials.token_grant.audience_overrides[{override_index}].{field}"),
"token_grant audience override selectors must not contain control characters",
));
}
}
for endpoint in endpoints {
for port in endpoint_ports(endpoint.port, &endpoint.ports) {
if !token_grant_override_matches_endpoint(override_config, &endpoint.host, port) {
@@ -5571,6 +5628,157 @@ credentials:
}
}
#[test]
fn validate_profile_set_rejects_control_characters_in_token_grant_keys() {
let valid = parse_profile_yaml(
r"
id: grant-key
display_name: Grant Key
credentials:
- name: access_token
auth_style: bearer
header_name: Authorization
token_grant:
token_endpoint: https://auth.example.com/token
endpoints:
- host: api.example.com
port: 443
protocol: rest
access: full
",
)
.unwrap();
for name in [
"access_token",
"namespace:access_token",
"\taccess_token",
"access_token\n",
] {
let mut profile = valid.clone();
profile.credentials[0].name = name.into();
let diagnostics = validate_profile_set(&[("profile.yaml".into(), profile)]);
if name.chars().any(char::is_control) {
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.field == "credentials.name"
&& diagnostic.message.contains("control characters")
}),
"{diagnostics:?}"
);
} else {
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
}
for (field, value) in [("host", "api.example.com\n"), ("path", "/api\t/private")] {
let mut profile = valid.clone();
if field == "host" {
profile.endpoints[0].host = value.into();
} else {
profile.endpoints[0].path = value.into();
}
let diagnostics = validate_profile_set(&[("profile.yaml".into(), profile)]);
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.field == format!("endpoints[0].{field}")
&& diagnostic.message.contains("control characters")
}),
"{diagnostics:?}"
);
let mut profile = valid.clone();
let grant = profile.credentials[0].token_grant.as_mut().unwrap();
grant
.audience_overrides
.push(super::TokenGrantAudienceOverrideProfile {
host: if field == "host" {
value.into()
} else {
String::new()
},
path: if field == "path" {
value.into()
} else {
String::new()
},
port: 0,
audience: String::new(),
scopes: Vec::new(),
});
let diagnostics = validate_profile_set(&[("profile.yaml".into(), profile)]);
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.field
== format!("credentials.token_grant.audience_overrides[0].{field}")
&& diagnostic.message.contains("control characters")
}),
"{diagnostics:?}"
);
}
}
#[test]
fn validate_profile_set_rejects_token_grants_without_http_injection() {
for (protocol, tls, token_grant, rejected_field) in [
("sql", "", true, Some("protocol")),
("rest", "skip", true, Some("tls")),
("graphql", "skip", true, Some("tls")),
("json-rpc", "skip", true, Some("tls")),
("mcp", "skip", true, Some("tls")),
("tcp", "skip", true, Some("tls")),
("", "skip", true, Some("tls")),
("rest", "", true, None),
("graphql", "", true, None),
("json-rpc", "", true, None),
("mcp", "", true, None),
("tcp", "", true, None),
("", "", true, None),
("tcp", "skip", false, None),
] {
let admission = match protocol {
"" | "tcp" => "",
"json-rpc" => " rules: [{allow: {method: echo}}]",
"mcp" => " rules: [{allow: {method: tools/call}}]",
_ => " access: full",
};
let mut profile = parse_profile_yaml(&format!(
r#"
id: grant-transport
display_name: Grant Transport
credentials:
- name: access_token
env_vars: [ACCESS_TOKEN]
auth_style: bearer
header_name: Authorization
token_grant:
token_endpoint: https://auth.example.com/token
audience: api://default
endpoints:
- host: api.example.com
port: 443
protocol: "{protocol}"
{admission}
tls: "{tls}"
allow_uninspected_credentials: true
binaries:
- /usr/bin/app
"#,
))
.expect("profile should parse");
if !token_grant {
profile.credentials[0].token_grant = None;
}
let diagnostics = validate_profile_set(&[("transport.yaml".to_string(), profile)]);
if let Some(field) = rejected_field {
assert_eq!(diagnostics.len(), 1, "{protocol}/{tls}: {diagnostics:?}");
assert_eq!(diagnostics[0].field, format!("endpoints[0].{field}"));
assert!(diagnostics[0].message.contains("token_grant"));
assert_eq!(diagnostics[0].severity, "error");
} else {
assert!(diagnostics.is_empty(), "{protocol}/{tls}: {diagnostics:?}");
}
}
}
#[test]
fn validate_profile_set_rejects_relative_token_endpoint() {
let profile = parse_profile_yaml(
@@ -103,8 +103,7 @@ impl SandboxProtocolAuthenticator {
let value = value
.to_str()
.map_err(|_| SandboxAuthError::InvalidBearer)?;
let token = value
.strip_prefix("Bearer ")
let token = openshell_core::auth::strip_bearer_scheme(value)
.filter(|token| !token.is_empty() && !token.chars().any(char::is_whitespace))
.ok_or(SandboxAuthError::InvalidBearer)?;
let session = self.verifier.verify(token)?;
@@ -459,6 +458,26 @@ mod tests {
);
}
#[test]
fn bearer_scheme_is_case_insensitive() {
let (authenticator, token) = fixture(1);
for scheme in ["bearer", "BEARER"] {
let mut metadata = MetadataMap::new();
metadata.insert(
"authorization",
format!("{scheme} {}", token.token.expose_secret())
.parse()
.expect("metadata value"),
);
assert!(
authenticator
.authenticate(SandboxConnectionId::new(), &metadata)
.is_ok(),
"{scheme}"
);
}
}
#[test]
fn reconnect_requires_disconnect_and_terminal_is_final() {
let (first_authenticator, first_token) = fixture(1);
+155 -24
View File
@@ -578,7 +578,15 @@ mod linux {
});
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {}
// A refused or already-dead peer must not stop the listener.
Err(error)
if matches!(
error.kind(),
io::ErrorKind::PermissionDenied
| io::ErrorKind::NotConnected
| io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
) => {}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(10));
}
@@ -3277,6 +3285,7 @@ mod linux {
Tcp {
listener: std::net::TcpListener,
server_config: Arc<rustls::ServerConfig>,
reject_local_peers: bool,
},
}
@@ -3305,22 +3314,23 @@ mod linux {
})
}
BoundaryListenerConfig::TlsTcp { address, tls } => {
let listener = Self::bind_tcp(*address)?;
let (listener, reject_local_peers) = Self::bind_tcp(*address)?;
listener.set_nonblocking(true)?;
let server_config = Arc::new(load_tls_server_config(tls)?);
Ok(Self::Tcp {
listener,
server_config,
reject_local_peers,
})
}
}
}
/// Bind the TCP control listener, dropping loopback-interface ingress
/// before it listens so workload sockets cannot reach it through
/// loopback or the pod's own address. Configuration rejects loopback
/// addresses; tests bind them without the filter.
fn bind_tcp(address: std::net::SocketAddr) -> io::Result<std::net::TcpListener> {
/// Bind the TCP control listener without privileged socket filters.
/// Production configuration rejects loopback bind addresses; only tests
/// use them to exercise TLS without the local-peer check. All production
/// TCP listeners reject local peers in `accept`, before TLS processing.
fn bind_tcp(address: std::net::SocketAddr) -> io::Result<(std::net::TcpListener, bool)> {
let socket = socket2::Socket::new(
socket2::Domain::for_address(address),
socket2::Type::STREAM,
@@ -3328,14 +3338,10 @@ mod linux {
)?;
socket.set_cloexec(true)?;
socket.set_reuse_address(true)?;
if !address.ip().is_loopback() {
openshell_isolation_interface::linux::socket_confinement::reject_loopback_ingress(
&socket,
)?;
}
let reject_local_peers = !address.ip().is_loopback();
socket.bind(&address.into())?;
socket.listen(128)?;
Ok(socket.into())
Ok((socket.into(), reject_local_peers))
}
fn bind_vsock(port: u32) -> io::Result<OwnedFd> {
@@ -3428,8 +3434,12 @@ mod linux {
Self::Tcp {
listener,
server_config,
reject_local_peers,
} => {
let (stream, _) = listener.accept()?;
let (stream, peer) = listener.accept()?;
if *reject_local_peers {
reject_workload_tcp_peer(&stream, peer)?;
}
if let Err(error) = stream.set_nodelay(true) {
tracing::debug!(%error, "Failed to set boundary TCP_NODELAY");
}
@@ -3442,6 +3452,28 @@ mod linux {
}
}
// Traffic the workload can reach the listener with arrives over `lo`, so one
// end is loopback or the two ends share an address. A supervisor runs in
// another pod and presents a different address. The peer comes from `accept`
// because `getpeername` fails once that peer has reset.
fn reject_workload_tcp_peer(
stream: &std::net::TcpStream,
peer: std::net::SocketAddr,
) -> io::Result<()> {
let denied = || io::Error::from_raw_os_error(libc::EACCES);
let local = stream.local_addr().map_err(|_| denied())?;
if is_local_control_peer(peer.ip(), local.ip()) {
return Err(denied());
}
Ok(())
}
fn is_local_control_peer(peer: std::net::IpAddr, local: std::net::IpAddr) -> bool {
let peer = peer.to_canonical();
let local = local.to_canonical();
peer.is_loopback() || local.is_loopback() || peer == local
}
fn reject_workload_unix_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> {
let mut credentials = libc::ucred {
pid: 0,
@@ -5138,7 +5170,7 @@ mod linux {
}
#[test]
fn pod_control_listener_rejects_loopback_ingress() {
fn pod_control_listener_rejects_loopback_peers_before_tls() {
let directory = tempfile::tempdir().expect("temporary directory");
let (server_tls, _client_tls) = stage_test_tls(directory.path(), "loopback");
let listener = ControlListener::bind(&BoundaryListenerConfig::TlsTcp {
@@ -5150,20 +5182,119 @@ mod linux {
.tcp_local_addr()
.expect("TLS listener address")
.port();
// Loopback and the host's own address both arrive on `lo`; the
// dropped SYN never completes a handshake.
let result = std::net::TcpStream::connect_timeout(
let _client = std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], port)),
Duration::from_millis(300),
)
.expect("TCP handshake succeeds without a packet filter");
assert_eq!(
accept_pending(&listener)
.expect_err("local peer refused")
.kind(),
io::ErrorKind::PermissionDenied
);
}
#[test]
fn control_peer_address_rules_cover_pod_and_loopback_addresses() {
for (peer, local, denied) in [
("127.0.0.2", "10.42.0.8", true),
("10.42.0.8", "127.0.0.1", true),
("10.42.0.8", "10.42.0.8", true),
("10.42.0.9", "10.42.0.8", false),
("::1", "fd00::8", true),
("fd00::8", "::1", true),
("fd00::8", "fd00::8", true),
("fd00::9", "fd00::8", false),
("::ffff:127.0.0.2", "10.42.0.8", true),
("10.42.0.8", "::ffff:127.0.0.1", true),
("::ffff:10.42.0.8", "10.42.0.8", true),
("10.42.0.8", "::ffff:10.42.0.8", true),
("::ffff:10.42.0.9", "::ffff:10.42.0.8", false),
] {
assert_eq!(
is_local_control_peer(peer.parse().unwrap(), local.parse().unwrap()),
denied,
"peer={peer}, local={local}"
);
}
}
/// A control listener in accept-time mode, bound to loopback so a test
/// client can actually reach it.
fn accept_time_control_listener(
directory: &Path,
name: &str,
) -> (ControlListener, std::net::SocketAddr) {
let (server_tls, _client_tls) = stage_test_tls(directory, name);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind listener");
listener
.set_nonblocking(true)
.expect("nonblocking listener");
let address = listener.local_addr().expect("listener address");
let server_config =
Arc::new(load_tls_server_config(&server_tls).expect("TLS server config"));
(
ControlListener::Tcp {
listener,
server_config,
reject_local_peers: true,
},
address,
)
}
fn accept_pending(listener: &ControlListener) -> io::Result<()> {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
match listener.accept() {
Err(error)
if error.kind() == io::ErrorKind::WouldBlock
&& std::time::Instant::now() < deadline =>
{
std::thread::sleep(Duration::from_millis(5));
}
other => return other.map(|_| ()),
}
}
}
#[test]
fn accept_refuses_a_same_host_control_peer() {
let directory = tempfile::tempdir().expect("temporary directory");
let (listener, address) = accept_time_control_listener(directory.path(), "local-peer");
let _client = std::net::TcpStream::connect(address).expect("connect loopback client");
let error = accept_pending(&listener).expect_err("loopback peer refused");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn accept_survives_a_control_peer_that_resets_before_it_is_accepted() {
let directory = tempfile::tempdir().expect("temporary directory");
let (listener, address) = accept_time_control_listener(directory.path(), "reset");
let client = socket2::Socket::new(
socket2::Domain::for_address(address),
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.expect("client socket");
client.connect(&address.into()).expect("connect");
// Zero linger makes the close an RST while the connection is still
// queued, which is what makes `getpeername` fail with ENOTCONN.
client
.set_linger(Some(Duration::ZERO))
.expect("zero linger");
drop(client);
let kind = accept_pending(&listener)
.expect_err("reset peer refused")
.kind();
assert!(
result.is_err(),
"loopback client reached the control listener"
matches!(
kind,
io::ErrorKind::PermissionDenied | io::ErrorKind::WouldBlock
),
"reset peer produced a listener-fatal error: {kind:?}"
);
assert!(matches!(
listener.accept().map(|_| ()),
Err(error) if error.kind() == io::ErrorKind::WouldBlock
));
}
#[test]
@@ -47,6 +47,17 @@ pub trait Authenticator: Send + Sync + 'static {
) -> Result<Option<Principal>, Status>;
}
/// Bearer credential from the request's `authorization` header, if any.
///
/// The scheme is matched case-insensitively. Non-Bearer or non-UTF-8 headers
/// yield `None` so the chain falls through.
pub fn bearer_credential(headers: &http::HeaderMap) -> Option<&str> {
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(openshell_core::auth::strip_bearer_scheme)
}
/// First-match-wins authenticator chain.
///
/// The chain owns its authenticators behind `Arc` so the entire chain is
@@ -150,6 +161,22 @@ mod tests {
})
}
#[test]
fn bearer_credential_matches_scheme_case_insensitively() {
for scheme in ["Bearer", "bearer", "BEARER"] {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
format!("{scheme} abc").parse().unwrap(),
);
assert_eq!(bearer_credential(&headers), Some("abc"), "{scheme}");
}
let mut headers = http::HeaderMap::new();
assert_eq!(bearer_credential(&headers), None);
headers.insert(http::header::AUTHORIZATION, "Basic abc".parse().unwrap());
assert_eq!(bearer_credential(&headers), None);
}
#[tokio::test]
async fn chain_returns_first_match() {
let first = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(
@@ -3,7 +3,7 @@
//! Compute-driver delegated sandbox bootstrap authentication.
use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
use crate::compute::ComputeRuntime;
use async_trait::async_trait;
@@ -34,11 +34,7 @@ impl Authenticator for ComputeDriverAuthenticator {
return Ok(None);
}
let Some(credential) = headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
else {
let Some(credential) = bearer_credential(headers) else {
return Ok(None);
};
@@ -118,6 +114,23 @@ mod tests {
));
}
#[tokio::test]
async fn accepts_lowercase_bearer_scheme() {
let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await;
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
http::HeaderValue::from_static("bearer driver-credential"),
);
let principal = auth
.authenticate(&headers, ISSUE_SANDBOX_TOKEN_PATH)
.await
.unwrap();
assert!(principal.is_some());
}
#[tokio::test]
async fn authenticator_is_scoped_to_issue_path() {
let auth = authenticator(NoopTestDriver::failing_sandbox_authentication(
+2 -6
View File
@@ -10,7 +10,7 @@
//! This module owns authentication (verifying who the caller is).
//! Authorization (deciding what the caller can do) is in `authz.rs`.
use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::identity::{Identity, IdentityProvider};
use super::principal::{Principal, UserPrincipal};
use async_trait::async_trait;
@@ -891,11 +891,7 @@ impl Authenticator for OidcAuthenticator {
headers: &http::HeaderMap,
_path: &str,
) -> Result<Option<Principal>, Status> {
let Some(token) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
let Some(token) = bearer_credential(headers) else {
return Ok(None);
};
+2 -6
View File
@@ -9,7 +9,7 @@
//! the apiserver `TokenReview` API, checks the live pod UID and required labels,
//! and only then produces a [`Principal::Peer`].
use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::principal::{PeerPrincipal, Principal};
use async_trait::async_trait;
use k8s_openapi::api::{
@@ -93,11 +93,7 @@ impl Authenticator for PeerServiceAccountAuthenticator {
return Ok(None);
}
let Some(token) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
let Some(token) = bearer_credential(headers) else {
return Ok(None);
};
@@ -11,7 +11,7 @@
//! Algorithm: `EdDSA` (Ed25519). Pinned via `Validation::algorithms` to
//! prevent algorithm-confusion attacks.
use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -277,11 +277,7 @@ impl Authenticator for SandboxSessionJwtAuthenticator {
headers: &http::HeaderMap,
path: &str,
) -> Result<Option<Principal>, Status> {
let Some(token) = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
else {
let Some(token) = bearer_credential(headers) else {
return Ok(None);
};
let Ok(header) = decode_header(token) else {
+1 -1
View File
@@ -4760,7 +4760,7 @@ impl ComputeRuntime {
Ok(())
}
async fn supervisor_session_ready(&self, sandbox_id: &str) -> Result<bool, String> {
pub(crate) async fn supervisor_session_ready(&self, sandbox_id: &str) -> Result<bool, String> {
if self.supervisor_sessions.has_session(sandbox_id) {
return Ok(true);
}
+97 -13
View File
@@ -28,6 +28,13 @@ pub const MEMBER_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
/// Upper bound on replicas read back when building the ring.
const MEMBER_LIST_LIMIT: u32 = 1024;
/// How long the worker waits on each store call while leaving at shutdown.
const SHUTDOWN_STORE_TIMEOUT: Duration = Duration::from_secs(2);
/// How long gateway shutdown waits for the membership worker to finish
/// leaving: one membership read and one deregister.
pub const SHUTDOWN_MEMBERSHIP_TIMEOUT: Duration = Duration::from_secs(5);
fn member_object_id(replica_id: &str) -> String {
format!("gateway-member:{replica_id}")
}
@@ -152,25 +159,28 @@ impl GatewayMemberIndex {
/// One task does both so the ring is never newer than our own liveness claim.
/// The ring starts empty, which means "serve locally" — so a store outage
/// degrades to today's behaviour rather than breaking placement.
///
/// Returns the worker task, which finishes after this replica leaves the ring
/// at shutdown. Shutdown awaits it before redirecting supervisor sessions.
pub fn spawn_membership_worker(
state: Arc<crate::ServerState>,
interval: Duration,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
) -> Option<tokio::task::JoinHandle<()>> {
if state.store.is_single_replica() {
return;
return None;
}
let Some(peer_endpoint) = state.peer_endpoint.clone() else {
// Without a peer endpoint other replicas cannot dial us, so we must
// not advertise ourselves as a placement target.
tracing::debug!("gateway membership: no peer endpoint, not joining the ring");
return;
return None;
};
let index = GatewayMemberIndex::new(state.store.clone(), MEMBER_TTL);
let replica_id = state.replica_id.clone();
tokio::spawn(async move {
Some(tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut last_refreshed = tokio::time::Instant::now();
@@ -180,14 +190,7 @@ pub fn spawn_membership_worker(
_ = ticker.tick() => {}
_ = shutdown.changed() => {
if *shutdown.borrow() {
// Sessions closing during shutdown redirect to the
// replica that owns them once this one is gone.
if let Ok(mut slot) = state.gateway_ring.write() {
*slot = slot.without(&replica_id);
}
if let Err(error) = index.deregister(&replica_id).await {
tracing::warn!(%error, "gateway membership: deregister failed");
}
leave_ring(&state, &index, &replica_id).await;
return;
}
continue;
@@ -220,7 +223,50 @@ pub fn spawn_membership_worker(
}
}
}
});
}))
}
/// Point the ring at the replicas that remain once this one is gone, then
/// remove this replica's record.
///
/// Sessions closing during shutdown redirect to the replica that owns them
/// next. The periodic refresh can be up to one interval old, and during a
/// rolling update that ring can miss a replacement that just started and still
/// name a replica that already left. Redirects built from it send supervisors
/// nowhere or to a dead pod, so re-read membership first. If the read fails,
/// keep the cached ring without this replica.
async fn leave_ring(state: &crate::ServerState, index: &GatewayMemberIndex, replica_id: &str) {
match tokio::time::timeout(SHUTDOWN_STORE_TIMEOUT, index.ring()).await {
Ok(Ok((ring, members))) => {
let peers: HashMap<String, String> = members
.into_iter()
.map(|member| (member.replica_id, member.peer_endpoint))
.collect();
if let Ok(mut slot) = state.gateway_ring.write() {
*slot = ring.without(replica_id);
}
if let Ok(mut slot) = state.gateway_peers.write() {
*slot = peers;
}
}
Ok(Err(error)) => {
tracing::warn!(%error, "gateway membership: shutdown ring refresh failed");
if let Ok(mut slot) = state.gateway_ring.write() {
*slot = slot.without(replica_id);
}
}
Err(_) => {
tracing::warn!("gateway membership: shutdown ring refresh timed out");
if let Ok(mut slot) = state.gateway_ring.write() {
*slot = slot.without(replica_id);
}
}
}
match tokio::time::timeout(SHUTDOWN_STORE_TIMEOUT, index.deregister(replica_id)).await {
Ok(Ok(())) => {}
Ok(Err(error)) => tracing::warn!(%error, "gateway membership: deregister failed"),
Err(_) => tracing::warn!("gateway membership: deregister timed out"),
}
}
fn clear_ring_if_stale(state: &crate::ServerState, last_refreshed: tokio::time::Instant) {
@@ -259,6 +305,44 @@ mod tests {
);
}
#[tokio::test]
async fn leaving_points_ring_at_new_replicas_and_not_departed_ones() {
let state = crate::grpc::test_support::test_server_state().await;
let index = GatewayMemberIndex::new(state.store.clone(), MEMBER_TTL);
index.register("gw-old", "https://gw-old").await.unwrap();
index
.register("gw-stopping", "https://gw-stopping")
.await
.unwrap();
*state.gateway_ring.write().unwrap() = GatewayRing::new(["gw-old", "gw-stopping"]);
// A rolling update replaces gw-old after the stopping replica last
// refreshed its cached ring.
index.deregister("gw-old").await.unwrap();
index.register("gw-new", "https://gw-new").await.unwrap();
leave_ring(&state, &index, "gw-stopping").await;
let ring = state.gateway_ring.read().unwrap().clone();
assert_eq!(ring.owner_for("sb-1"), Some("gw-new"));
assert_eq!(
state
.gateway_peers
.read()
.unwrap()
.get("gw-new")
.map(String::as_str),
Some("https://gw-new")
);
let remaining: Vec<String> = index
.live_members()
.await
.unwrap()
.into_iter()
.map(|member| member.replica_id)
.collect();
assert_eq!(remaining, vec!["gw-new"]);
}
#[tokio::test]
async fn register_is_idempotent_and_updates_the_endpoint() {
let index = test_index(MEMBER_TTL).await;
+1 -1
View File
@@ -200,7 +200,7 @@ pub async fn handle_refresh_sandbox_token(
let gateway_token = authorization
.to_str()
.ok()
.and_then(|value| value.strip_prefix("Bearer "))
.and_then(openshell_core::auth::strip_bearer_scheme)
.ok_or_else(|| Status::unauthenticated("invalid bearer authorization metadata"))?;
let principal = session_authority.verify_gateway_token(gateway_token)?;
if principal.sandbox_id.as_str() != sandbox.sandbox_id {
+123 -12
View File
@@ -12,6 +12,8 @@
mod endpoint_status;
mod provisioning_clock;
#[cfg(test)]
mod token_grant_owners_tests;
pub use provisioning_clock::configuration_change;
pub(super) use endpoint_status::{
@@ -811,7 +813,7 @@ fn evaluate_proposal_candidate(
rule_name: rule_name.clone(),
rule: rule.clone(),
}];
let candidate_base = match merge_policy(base_policy.clone(), &operations) {
let mut candidate_base = match merge_policy(base_policy.clone(), &operations) {
Ok(result) => result.policy,
Err(error) => {
let application_error = format!("merge failed: {}", one_line(&error.to_string()));
@@ -833,6 +835,7 @@ fn evaluate_proposal_candidate(
}
};
clear_provider_credentialed_markers(&mut candidate_base);
let validation = (|| -> Result<ProtoSandboxPolicy, Status> {
validate_policy_safety(&candidate_base)?;
validate_candidate_effective_policy(&candidate_base, validation_context.provider_layers)?;
@@ -1921,7 +1924,9 @@ async fn current_effective_policy_for_sandbox(
&provider_names,
)
.await?;
current_effective_policy_from_records(state, catalog, sandbox, sandbox_id, &records).await
current_effective_policy_from_records(state, catalog, sandbox, sandbox_id, &records)
.await
.map(|(policy, _)| policy)
}
async fn current_effective_policy_from_records(
@@ -1930,7 +1935,7 @@ async fn current_effective_policy_from_records(
sandbox: &Sandbox,
sandbox_id: &str,
records: &[super::provider::ProviderEnvironmentRecord],
) -> Result<ProtoSandboxPolicy, Status> {
) -> Result<(ProtoSandboxPolicy, PolicySource), Status> {
let global_settings = load_global_settings(state.store.as_ref()).await?;
if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? {
// A global policy is the complete effective policy. Dormant sandbox
@@ -1940,7 +1945,8 @@ async fn current_effective_policy_from_records(
provider_policy_context_from_records(catalog, records),
global_policy,
PolicySource::Global,
);
)
.map(|policy| (policy, PolicySource::Global));
}
let policy = if let Some(record) = state
@@ -1964,6 +1970,7 @@ async fn current_effective_policy_from_records(
policy,
PolicySource::Sandbox,
)
.map(|policy| (policy, PolicySource::Sandbox))
}
async fn effective_policy_for_source(
@@ -2027,6 +2034,9 @@ fn apply_captured_policy_context(
&provider_context.endpointless_provider_names,
);
stamp_provider_credentialed_endpoints(&mut policy, &provider_context.credentialed_scopes);
if matches!(policy_source, PolicySource::Global) {
openshell_core::policy_identity::stamp_global_token_grant_owners(&mut policy);
}
Ok(policy)
}
@@ -2936,6 +2946,9 @@ pub(super) async fn load_sandbox_config(
effective_policy,
&provider_policy_context.credentialed_scopes,
);
if matches!(policy_source, PolicySource::Global) {
openshell_core::policy_identity::stamp_global_token_grant_owners(effective_policy);
}
if let Err(error) = validate_uninspected_credentialed_endpoints(effective_policy) {
configuration_error = bounded_configuration_diagnostic(error.message());
}
@@ -2976,6 +2989,9 @@ pub(super) async fn load_sandbox_config(
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
policy
.as_ref()
.filter(|_| matches!(policy_source, PolicySource::Global)),
)?;
Ok(GetSandboxConfigResponse {
@@ -3113,6 +3129,7 @@ fn compute_provider_env_revision_from_records(
catalog,
records,
&HashMap::new(),
None,
)
}
@@ -3120,6 +3137,7 @@ fn compute_provider_env_revision_from_records_and_policy_bindings(
catalog: &EffectiveProviderProfileCatalog,
records: &[super::provider::ProviderEnvironmentRecord],
policy_bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
global_policy: Option<&ProtoSandboxPolicy>,
) -> Result<u64, Status> {
let mut hasher = Sha256::new();
hasher.update(b"openshell-provider-env-revision-v4");
@@ -3156,6 +3174,13 @@ fn compute_provider_env_revision_from_records_and_policy_bindings(
hash_policy_credential_bindings(policy_bindings, &mut hasher);
if let Some(policy) = global_policy {
// Global policy replaces the grant owner list. Its changes must trigger
// a provider refetch even when the attached provider records are unchanged.
hasher.update(b"global-token-grant-authority");
hasher.update(deterministic_policy_hash(policy).as_bytes());
}
let digest = hasher.finalize();
Ok(u64::from_le_bytes(digest[..8].try_into().map_err(
|_| Status::internal("provider env revision digest too short"),
@@ -3310,6 +3335,11 @@ fn provider_policy_context_from_records(
let rule_name = openshell_policy::provider_rule_name(provider.object_name());
let mut rule = profile.network_policy_rule(&rule_name);
let owners =
openshell_core::policy_identity::provider_token_grant_owners(&record.object_id, &rule);
for (endpoint, owner) in rule.endpoints.iter_mut().zip(owners) {
endpoint.token_grant_owner = owner;
}
if rule.endpoints.is_empty() {
endpointless_provider_names.insert(name.clone());
}
@@ -3397,6 +3427,7 @@ pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolic
for rule in policy.network_policies.values_mut() {
for endpoint in &mut rule.endpoints {
endpoint.provider_credentialed = false;
endpoint.token_grant_owner.clear();
}
}
}
@@ -3556,7 +3587,7 @@ pub(super) async fn load_sandbox_provider_environment(
&provider_names,
)
.await?;
let effective_policy = current_effective_policy_from_records(
let (effective_policy, policy_source) = current_effective_policy_from_records(
state.as_ref(),
&provider_profile_catalog,
sandbox,
@@ -3576,6 +3607,7 @@ pub(super) async fn load_sandbox_provider_environment(
&provider_profile_catalog,
&provider_records,
&policy_credential_bindings,
matches!(policy_source, PolicySource::Global).then_some(&effective_policy),
)?;
let mut provider_environment =
super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials(
@@ -3588,6 +3620,25 @@ pub(super) async fn load_sandbox_provider_environment(
)
.await?;
if matches!(policy_source, PolicySource::Global) {
// A global policy replaces provider ACLs. Grants retain their profile
// destination selectors, but only the selected global endpoint may
// authorize their use. Keeping every global owner avoids reimplementing
// host/path intersection here; the relay checks both selectors.
let mut owners: Vec<_> = effective_policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.map(|endpoint| endpoint.token_grant_owner.clone())
.filter(|owner| !owner.is_empty())
.collect();
owners.sort();
owners.dedup();
for credential in provider_environment.dynamic_credentials.values_mut() {
credential.token_grant_owners.clone_from(&owners);
}
}
let mut readiness_reason = provider_environment.readiness_reason;
if supports_static_credential_bindings {
@@ -7121,7 +7172,8 @@ fn validate_operator_merged_credential_policy(
bindings,
context.endpointless_provider_names,
);
clear_provider_credentialed_markers(effective_policy);
// Recompute the credential flags without clearing owner IDs that provider
// composition already derived for this effective policy.
stamp_provider_credentialed_endpoints(effective_policy, &credentialed_scopes);
validate_uninspected_credentialed_endpoints(effective_policy)
}
@@ -7134,7 +7186,8 @@ fn stage_validated_merge_operation(
validate_merge_operations_for_server(std::slice::from_ref(operation))?;
let merged = merge_policy(current_policy.clone(), std::slice::from_ref(operation))
.map_err(map_policy_merge_error)?;
let candidate = merged.policy;
let mut candidate = merged.policy;
clear_provider_credentialed_markers(&mut candidate);
validate_policy_safety(&candidate)?;
validate_candidate_effective_policy(&candidate, validation_context.provider_layers)?;
let mut effective = if validation_context.provider_layers.is_empty() {
@@ -7167,13 +7220,14 @@ async fn apply_merge_operations_with_retry(
.await
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
let (current_policy, current_hash) = if let Some(ref record) = latest {
let (mut current_policy, current_hash) = if let Some(ref record) = latest {
let (policy, hash) = canonical_policy_record_identity(record)?;
(policy, Some(hash))
} else {
(baseline_policy.cloned().unwrap_or_default(), None)
};
clear_provider_credentialed_markers(&mut current_policy);
if let Some(expected_hash) = expected_current_effective_hash {
let mut current_effective = if provider_layers.is_empty() {
current_policy.clone()
@@ -7196,7 +7250,8 @@ async fn apply_merge_operations_with_retry(
}
let merged = merge_policy(current_policy, operations).map_err(map_policy_merge_error)?;
let new_policy = merged.policy;
let mut new_policy = merged.policy;
clear_provider_credentialed_markers(&mut new_policy);
let hash = deterministic_policy_hash(&new_policy);
if let Some(baseline_policy) = baseline_policy {
@@ -8527,9 +8582,11 @@ mod tests {
let settings = load_global_settings(state.store.as_ref())
.await
.expect("test global settings lookup");
decode_policy_from_global_settings(&settings)
let mut policy = decode_policy_from_global_settings(&settings)
.expect("test global policy must decode")
.expect("test global policy must be present")
.expect("test global policy must be present");
openshell_core::policy_identity::stamp_global_token_grant_owners(&mut policy);
policy
}
#[tokio::test]
@@ -11265,7 +11322,7 @@ mod tests {
assert!(loaded.spec.unwrap().policy.is_none());
}
fn test_provider(name: &str, provider_type: &str) -> Provider {
pub(super) fn test_provider(name: &str, provider_type: &str) -> Provider {
Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: format!("provider-{name}"),
@@ -18814,6 +18871,19 @@ mod tests {
read_write: vec!["/sandbox".to_string()],
..Default::default()
}),
network_policies: HashMap::from([(
"authored_existing".to_string(),
NetworkPolicyRule {
name: "authored_existing".to_string(),
endpoints: vec![NetworkEndpoint {
host: "api.example.test".to_string(),
port: 443,
token_grant_owner: "forged-source-owner".to_string(),
..Default::default()
}],
binaries: Vec::new(),
},
)]),
..Default::default()
}),
providers: vec!["work-custom".to_string()],
@@ -18823,6 +18893,19 @@ mod tests {
};
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let stored_source = sandbox.spec.as_ref().unwrap().policy.as_ref().unwrap();
state
.store
.put_policy_revision(
"policy-agent-provider-effective-policy",
sandbox_id,
"default",
1,
&stored_source.encode_to_vec(),
&deterministic_policy_hash(stored_source),
)
.await
.unwrap();
let proposed_rule = NetworkPolicyRule {
name: "github_contents_write".to_string(),
@@ -18842,6 +18925,7 @@ mod tests {
}),
}],
advisor_proposed: true,
token_grant_owner: "forged-proposal-owner".to_string(),
..Default::default()
}],
binaries: vec![NetworkBinary {
@@ -18918,6 +19002,20 @@ mod tests {
.await
.expect("provider-overlap proposal should approve");
let candidate = chunk.candidate_effective_policy.as_ref().unwrap();
for rule_name in ["authored_existing", "github_contents_write"] {
assert!(
candidate.network_policies[rule_name].endpoints[0]
.token_grant_owner
.is_empty()
);
}
assert!(
!candidate.network_policies["_provider_work_custom"].endpoints[0]
.token_grant_owner
.is_empty()
);
let stored = state
.store
.get_latest_policy(sandbox_id)
@@ -18937,7 +19035,19 @@ mod tests {
"provider-composed rules must not be copied into the mutable base policy"
);
assert!(
base_policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.all(|endpoint| endpoint.token_grant_owner.is_empty())
);
let effective_policy = get_sandbox_policy(&state, sandbox_id).await;
assert_eq!(
deterministic_policy_hash(&effective_policy),
chunk.candidate_effective_policy_hash
);
let provider_rule = &effective_policy.network_policies["_provider_work_custom"];
assert_eq!(
provider_rule.endpoints[0].access,
@@ -18945,6 +19055,7 @@ mod tests {
);
assert_eq!(provider_rule.endpoints[0].deny_rules.len(), 1);
assert!(!provider_rule.endpoints[0].advisor_proposed);
assert!(!provider_rule.endpoints[0].token_grant_owner.is_empty());
assert!(
effective_policy
.network_policies
@@ -0,0 +1,290 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Verify that the gateway publishes the same authority to policy and credential consumers.
use super::*;
use crate::grpc::test_support::test_server_state;
use openshell_core::proto::{
NetworkAccessPreset, NetworkEnforcementMode, ProviderCredentialTokenGrant,
ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCredential,
};
fn profile(id: &str, path: &str) -> ProviderProfile {
ProviderProfile {
id: id.into(),
display_name: id.into(),
credentials: vec![ProviderProfileCredential {
name: "access_token".into(),
auth_style: "bearer".into(),
header_name: "Authorization".into(),
token_grant_owners: vec!["forged-profile-owner".into()],
token_grant: Some(ProviderCredentialTokenGrant {
token_endpoint: "https://identity.example.test/token".into(),
audience: "default-resource".into(),
audience_overrides: vec![ProviderCredentialTokenGrantAudienceOverride {
path: format!("{}/restricted/**", path.trim_end_matches("/**")),
audience: "restricted-resource".into(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
}],
endpoints: vec![NetworkEndpoint {
host: "api.example.test".into(),
port: 443,
protocol: "rest".into(),
access: NetworkAccessPreset::ReadOnly.into(),
enforcement: NetworkEnforcementMode::Enforce.into(),
path: path.into(),
token_grant_owner: "forged-endpoint-owner".into(),
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/profile-client".into(),
}],
..Default::default()
}
}
async fn store_profile(state: &Arc<ServerState>, profile: ProviderProfile) {
let mut stored = crate::provider_profile_sources::stored_provider_profile(profile);
if let Some(existing) = state
.store
.get_message_by_name::<StoredProviderProfile>("", stored.object_name())
.await
.unwrap()
{
stored.metadata = existing.metadata;
}
state.store.put_message(&stored).await.unwrap();
}
async fn provider_sandbox(state: &Arc<ServerState>) -> Sandbox {
let mut providers = Vec::new();
for (name, id, path) in [
("team-a", "owner-a", "/alpha/**"),
("team_a", "owner-b", "/beta/**"),
] {
store_profile(state, profile(id, path)).await;
let mut provider = tests::test_provider(name, id);
provider.credentials.clear();
provider.profile_workspace.clear();
state.store.put_message(&provider).await.unwrap();
providers.push(name.to_string());
}
let sandbox = tests::test_sandbox(
"sb-token-grant-owner",
"token-grant-owner",
ProtoSandboxPolicy::default(),
providers,
);
state.store.put_message(&sandbox).await.unwrap();
sandbox
}
#[tokio::test]
async fn composed_owners_survive_name_collisions_and_audience_overrides() {
let state = test_server_state().await;
let sandbox = provider_sandbox(&state).await;
let config = load_sandbox_config(&state, &sandbox).await.unwrap();
let policy = config.policy.unwrap();
assert!(policy.network_policies.contains_key("_provider_team_a"));
assert!(policy.network_policies.contains_key("_provider_team_a_2"));
let environment = load_sandbox_provider_environment(&state, &sandbox, true, true)
.await
.unwrap();
assert_eq!(environment.policy_hash, deterministic_policy_hash(&policy));
for (provider, path) in [("team-a", "/alpha/**"), ("team_a", "/beta/**")] {
let endpoint = policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.find(|endpoint| endpoint.path == path)
.unwrap();
assert!(endpoint.token_grant_owner.starts_with("grant-owner:v1:"));
let credentials: Vec<_> = environment
.dynamic_credentials
.iter()
.filter(|(key, _)| key.ends_with(&format!("\t{provider}:access_token")))
.collect();
assert_eq!(
credentials.len(),
2,
"default and override must retain an owner"
);
for (key, credential) in credentials {
assert_eq!(
credential.token_grant_owners.as_slice(),
std::slice::from_ref(&endpoint.token_grant_owner)
);
assert!(key.contains(&endpoint.token_grant_owner));
}
}
let owners: HashSet<_> = policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.map(|endpoint| &endpoint.token_grant_owner)
.collect();
assert_eq!(owners.len(), 2, "sanitized names cannot alias providers");
}
#[tokio::test]
async fn refreshed_profile_owners_do_not_match_the_previous_policy() {
let state = test_server_state().await;
let sandbox = provider_sandbox(&state).await;
let first = load_sandbox_config(&state, &sandbox)
.await
.unwrap()
.policy
.unwrap();
let first_owner = first
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.find(|endpoint| endpoint.path == "/alpha/**")
.unwrap()
.token_grant_owner
.clone();
let mut replacement = profile("owner-a", "/alpha/**");
replacement.binaries[0].path = "/usr/bin/replacement-client".into();
store_profile(&state, replacement).await;
let environment = load_sandbox_provider_environment(&state, &sandbox, true, true)
.await
.unwrap();
let replacement_policy = load_sandbox_config(&state, &sandbox)
.await
.unwrap()
.policy
.unwrap();
let replacement_owner = &replacement_policy
.network_policies
.values()
.flat_map(|rule| &rule.endpoints)
.find(|endpoint| endpoint.path == "/alpha/**")
.unwrap()
.token_grant_owner;
assert_ne!(&first_owner, replacement_owner);
for (key, credential) in &environment.dynamic_credentials {
if key.ends_with("\tteam-a:access_token") {
assert_eq!(
credential.token_grant_owners.as_slice(),
std::slice::from_ref(replacement_owner)
);
assert!(!credential.token_grant_owners.contains(&first_owner));
}
}
}
#[tokio::test]
async fn global_policy_replaces_grant_authorities_and_preserves_profile_destinations() {
let state = test_server_state().await;
let sandbox = provider_sandbox(&state).await;
let before = load_sandbox_provider_environment(&state, &sandbox, true, true)
.await
.unwrap();
let mut global = ProtoSandboxPolicy {
network_policies: HashMap::from([(
"global_api".into(),
NetworkPolicyRule {
name: "global_api".into(),
endpoints: vec![NetworkEndpoint {
host: "api.example.test".into(),
port: 443,
protocol: "rest".into(),
path: "/**".into(),
access: NetworkAccessPreset::Full.into(),
enforcement: NetworkEnforcementMode::Enforce.into(),
token_grant_owner: "forged-global-owner".into(),
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/global-client".into(),
}],
},
)]),
..Default::default()
};
// Persist an untrusted stamp to exercise the gateway's clearing path.
let settings = StoredSettings {
revision: 1,
settings: BTreeMap::from([(
POLICY_SETTING_KEY.into(),
StoredSettingValue::Bytes(hex::encode(global.encode_to_vec())),
)]),
..Default::default()
};
save_global_settings(state.store.as_ref(), &settings)
.await
.unwrap();
let config = load_sandbox_config(&state, &sandbox).await.unwrap();
assert_eq!(config.policy_source, PolicySource::Global as i32);
let global_provider_revision = config.provider_env_revision;
global = config.policy.unwrap();
assert_eq!(global.network_policies.len(), 1);
let global_owner = &global.network_policies["global_api"].endpoints[0].token_grant_owner;
assert_ne!(global_owner, "forged-global-owner");
let after = load_sandbox_provider_environment(&state, &sandbox, true, true)
.await
.unwrap();
assert_ne!(before.provider_env_revision, after.provider_env_revision);
assert_eq!(global_provider_revision, after.provider_env_revision);
assert_eq!(after.policy_hash, deterministic_policy_hash(&global));
assert_eq!(
after.dynamic_credentials.len(),
before.dynamic_credentials.len()
);
for (key, credential) in &after.dynamic_credentials {
assert!(
before.dynamic_credentials.contains_key(key),
"global rules do not widen credential selectors"
);
assert_eq!(
credential.token_grant_owners.as_slice(),
std::slice::from_ref(global_owner)
);
assert_ne!(
credential.token_grant_owners,
before.dynamic_credentials[key].token_grant_owners
);
}
let previous_owner = global_owner.clone();
global
.network_policies
.get_mut("global_api")
.unwrap()
.binaries[0]
.path = "/usr/bin/replacement-global-client".into();
let mut replacement_settings = load_global_settings(state.store.as_ref()).await.unwrap();
replacement_settings.revision += 1;
replacement_settings.settings.insert(
POLICY_SETTING_KEY.into(),
StoredSettingValue::Bytes(hex::encode(global.encode_to_vec())),
);
save_global_settings(state.store.as_ref(), &replacement_settings)
.await
.unwrap();
let replacement_config = load_sandbox_config(&state, &sandbox).await.unwrap();
let replacement_environment = load_sandbox_provider_environment(&state, &sandbox, true, true)
.await
.unwrap();
assert_ne!(
after.provider_env_revision,
replacement_environment.provider_env_revision
);
assert_eq!(
replacement_config.provider_env_revision,
replacement_environment.provider_env_revision
);
assert!(
replacement_environment
.dynamic_credentials
.values()
.all(|credential| !credential.token_grant_owners.contains(&previous_owner))
);
}
+199 -50
View File
@@ -20,6 +20,7 @@ use crate::storage_proto::{
StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState,
StoredProviderProfile,
};
use openshell_core::dynamic_credential_key::DynamicCredentialKey;
use openshell_core::metadata::ObjectWorkspace;
use openshell_core::proto::{
CredentialHandle, Provider, ProviderCredentialRefreshStrategy,
@@ -1457,7 +1458,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin
readiness_reason,
environment: env,
credential_expiration_times: expires,
dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records),
dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records)?,
static_credential_bindings,
static_credential_keys,
stable_placeholder_environment_keys,
@@ -1578,7 +1579,7 @@ fn hash_handle_component(hasher: &mut Sha256, value: &[u8]) {
fn resolve_dynamic_credentials_from_records(
catalog: &EffectiveProviderProfileCatalog,
records: &[ProviderEnvironmentRecord],
) -> HashMap<String, ProviderProfileCredential> {
) -> Result<HashMap<String, ProviderProfileCredential>, Status> {
let mut dynamic_creds = HashMap::new();
for record in records {
let provider = &record.provider;
@@ -1591,36 +1592,48 @@ fn resolve_dynamic_credentials_from_records(
};
insert_dynamic_credentials_for_profile(
&mut dynamic_creds,
&profile.to_proto(),
&profile,
&record.name,
);
&record.object_id,
)?;
}
dynamic_creds
Ok(dynamic_creds)
}
fn insert_dynamic_credentials_for_profile(
dynamic_creds: &mut HashMap<String, ProviderProfileCredential>,
profile: &ProviderProfile,
profile: &ProviderTypeProfile,
provider_name: &str,
) {
provider_id: &str,
) -> Result<(), Status> {
// Display names and collision suffixes never participate in credential ownership.
let rule = profile.network_policy_rule("");
let owners = openshell_core::policy_identity::provider_token_grant_owners(provider_id, &rule);
let profile = profile.to_proto();
for credential in &profile.credentials {
if credential.token_grant.is_none() {
continue;
}
for endpoint in &profile.endpoints {
for port in endpoint_ports(endpoint.port, &endpoint.ports) {
for (endpoint, owner) in profile.endpoints.iter().zip(&owners) {
let ports = if endpoint.ports.is_empty() {
std::slice::from_ref(&endpoint.port)
} else {
endpoint.ports.as_slice()
};
for &port in ports {
insert_dynamic_credentials_for_endpoint(
dynamic_creds,
&endpoint.host,
port,
&endpoint.path,
provider_name,
&credential.name,
credential,
);
owner,
)?;
}
}
}
Ok(())
}
fn endpoint_ports(port: u32, ports: &[u32]) -> Vec<u32> {
@@ -1631,49 +1644,35 @@ fn endpoint_ports(port: u32, ports: &[u32]) -> Vec<u32> {
}
}
fn dynamic_credential_key(
host: &str,
port: u32,
path: &str,
provider_name: &str,
credential_name: &str,
) -> String {
format!(
"{}\t{port}\t{}\t{}:{}",
host.to_ascii_lowercase(),
path,
provider_name,
credential_name
)
}
fn insert_dynamic_credentials_for_endpoint(
dynamic_creds: &mut HashMap<String, ProviderProfileCredential>,
endpoint_host: &str,
endpoint_port: u32,
endpoint_path: &str,
provider_name: &str,
credential_name: &str,
credential: &ProviderProfileCredential,
) {
let default_key = dynamic_credential_key(
owner: &str,
) -> Result<(), Status> {
let default_key = DynamicCredentialKey::new(
endpoint_host,
endpoint_port,
endpoint_path,
owner,
provider_name,
credential_name,
&credential.name,
)
.map_err(|error| Status::failed_precondition(error.to_string()))?
.encode();
dynamic_creds.insert(
default_key,
resolved_dynamic_credential(credential, None, owner),
);
dynamic_creds.insert(default_key, resolved_dynamic_credential(credential, None));
let Some(token_grant) = credential.token_grant.as_ref() else {
return;
return Ok(());
};
for override_config in &token_grant.audience_overrides {
if !token_grant_override_matches_endpoint(override_config, endpoint_host, endpoint_port) {
continue;
}
let override_host = if override_config.host.is_empty() {
endpoint_host
} else {
@@ -1689,25 +1688,35 @@ fn insert_dynamic_credentials_for_endpoint(
} else {
override_config.path.as_str()
};
let override_key = dynamic_credential_key(
let override_key = DynamicCredentialKey::new(
override_host,
override_port,
override_path,
owner,
provider_name,
credential_name,
);
&credential.name,
)
.map_err(|error| Status::failed_precondition(error.to_string()))?
.encode();
if !token_grant_override_matches_endpoint(override_config, endpoint_host, endpoint_port) {
continue;
}
dynamic_creds.insert(
override_key,
resolved_dynamic_credential(credential, Some(override_config)),
resolved_dynamic_credential(credential, Some(override_config), owner),
);
}
Ok(())
}
fn resolved_dynamic_credential(
credential: &ProviderProfileCredential,
override_config: Option<&ProviderCredentialTokenGrantAudienceOverride>,
owner: &str,
) -> ProviderProfileCredential {
let mut credential = credential.clone();
// Authored profile metadata cannot nominate its own policy authority.
credential.token_grant_owners = vec![owner.to_string()];
if let Some(token_grant) = credential.token_grant.as_mut() {
if let Some(override_config) = override_config {
if !override_config.audience.is_empty() {
@@ -5433,6 +5442,7 @@ mod tests {
query_param: String::new(),
refresh: None,
path_template: String::new(),
token_grant_owners: vec!["untrusted-profile-owner".to_string()],
token_grant: Some(ProviderCredentialTokenGrant {
grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32,
token_endpoint: "http://keycloak.default.svc.cluster.local/realms/openshell/protocol/openid-connect/token".to_string(),
@@ -5484,18 +5494,140 @@ mod tests {
};
let mut dynamic_creds = HashMap::new();
insert_dynamic_credentials_for_profile(&mut dynamic_creds, &profile, "keycloak");
insert_dynamic_credentials_for_profile(
&mut dynamic_creds,
&ProviderTypeProfile::from_proto(&profile),
"keycloak",
"provider-uid",
)
.expect("valid token grant keys");
assert_eq!(dynamic_creds.len(), 4);
for (host, audience) in service_audiences {
let key = dynamic_credential_key(host, 80, "", "keycloak", "access_token");
let grant = dynamic_creds[&key].token_grant.as_ref().unwrap();
let (key, credential) = dynamic_creds
.iter()
.find(|(key, _)| key.starts_with(host))
.unwrap();
assert_eq!(credential.token_grant_owners.len(), 1);
assert_ne!(credential.token_grant_owners[0], "untrusted-profile-owner");
assert!(key.contains(&credential.token_grant_owners[0]));
let grant = credential.token_grant.as_ref().unwrap();
assert_eq!(grant.audience, audience);
assert_eq!(grant.scopes, vec![audience.to_string()]);
assert!(grant.audience_overrides.is_empty());
}
}
#[test]
fn dynamic_token_grant_key_errors_reject_unvalidated_profiles() {
for (name, port) in [
("access_token", 443),
("namespace:access_token", 443),
("\taccess_token", 443),
("access_token\n", 443),
("access_token", 0),
("access_token", 65_536),
] {
let profile = ProviderProfile {
credentials: vec![token_grant_credential(name)],
endpoints: vec![NetworkEndpoint {
host: "api.example.test".into(),
port,
..Default::default()
}],
..Default::default()
};
let mut dynamic = HashMap::new();
let result = insert_dynamic_credentials_for_profile(
&mut dynamic,
&ProviderTypeProfile::from_proto(&profile),
"provider",
"provider-uid",
);
if name.chars().any(char::is_control) || !(1..=65_535).contains(&port) {
assert_eq!(result.unwrap_err().code(), Code::FailedPrecondition);
assert!(dynamic.is_empty(), "invalid key must not be published");
} else {
result.expect("valid credential key");
assert_eq!(dynamic.len(), 1);
}
}
}
#[tokio::test]
async fn provider_environment_propagates_dynamic_key_failure() {
let state = test_server_state().await;
let store = state.store.as_ref();
import_token_grant_profile(&state, "grant-key", "api.example.test", 443, "/api/**").await;
create_empty_token_grant_provider(store, "provider", "grant-key").await;
let catalog = ProviderProfileSources::with_default_sources()
.snapshot_catalog(store, "default")
.await
.unwrap();
let mut records = load_provider_environment_records(store, "default", &["provider".into()])
.await
.unwrap();
let valid = resolve_provider_environment_from_records(store, &catalog, &records)
.await
.expect("valid dynamic environment");
assert_eq!(valid.dynamic_credentials.len(), 1);
for malformed_name in ["provider\tother", "provider\n", "provider:other"] {
records[0].name = malformed_name.into();
let error = resolve_provider_environment_from_records(store, &catalog, &records)
.await
.expect_err("invalid record key must reject the whole environment");
assert_eq!(error.code(), Code::FailedPrecondition);
}
}
#[test]
fn overlapping_override_selectors_preserve_their_original_endpoint_owners() {
let mut credential = token_grant_credential("access_token");
credential.token_grant.as_mut().unwrap().audience_overrides =
vec![ProviderCredentialTokenGrantAudienceOverride {
path: "/api/private/**".into(),
audience: "private-resource".into(),
..Default::default()
}];
let profile = ProviderProfile {
credentials: vec![credential],
endpoints: ["/api/**", "/api/private/**"]
.into_iter()
.map(|path| NetworkEndpoint {
host: "api.example.test".into(),
port: 443,
path: path.into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut credentials = HashMap::new();
insert_dynamic_credentials_for_profile(
&mut credentials,
&ProviderTypeProfile::from_proto(&profile),
"api",
"provider-uid",
)
.expect("valid token grant keys");
let private: Vec<_> = credentials
.iter()
.filter(|(key, _)| key.starts_with("api.example.test\t443\t/api/private/**\t"))
.collect();
assert_eq!(
private.len(),
2,
"identical override selectors cannot erase an owner"
);
assert_ne!(
private[0].1.token_grant_owners,
private[1].1.token_grant_owners
);
assert!(private.iter().all(|(_, credential)| {
credential.token_grant.as_ref().unwrap().audience == "private-resource"
}));
}
async fn import_token_grant_profile(
state: &Arc<ServerState>,
id: &str,
@@ -5615,20 +5747,33 @@ mod tests {
.snapshot_catalog(store, "default")
.await
.unwrap();
let profile = get_provider_type_profile_for_scope(&catalog, "grant-pair", "default")
.unwrap()
.to_proto();
let profile =
get_provider_type_profile_for_scope(&catalog, "grant-pair", "default").unwrap();
let mut credentials = HashMap::new();
insert_dynamic_credentials_for_profile(&mut credentials, &profile, "provider");
insert_dynamic_credentials_for_profile(
&mut credentials,
&profile,
"provider",
"provider-uid",
)
.expect("valid token grant keys");
assert_eq!(credentials.len(), 2);
let owners = openshell_core::policy_identity::provider_token_grant_owners(
"provider-uid",
&profile.network_policy_rule(""),
);
assert_eq!(owners.len(), 1);
for expected in [service, identity] {
let key = dynamic_credential_key(
let key = DynamicCredentialKey::new(
"api.example.com",
443,
"/v1/**",
&owners[0],
"provider",
&expected.name,
);
)
.expect("valid token grant key")
.encode();
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());
@@ -6386,6 +6531,7 @@ mod tests {
fn refreshable_credential(name: &str, env_var: &str) -> ProviderProfileCredential {
ProviderProfileCredential {
token_grant_owners: Vec::new(),
name: name.to_string(),
description: String::new(),
env_vars: vec![env_var.to_string()],
@@ -6456,6 +6602,7 @@ mod tests {
fn static_credential(name: &str, env_var: &str, required: bool) -> ProviderProfileCredential {
ProviderProfileCredential {
token_grant_owners: Vec::new(),
name: name.to_string(),
description: String::new(),
env_vars: vec![env_var.to_string()],
@@ -6472,6 +6619,7 @@ mod tests {
fn token_grant_credential(name: &str) -> ProviderProfileCredential {
ProviderProfileCredential {
token_grant_owners: Vec::new(),
name: name.to_string(),
description: String::new(),
env_vars: Vec::new(),
@@ -10587,6 +10735,7 @@ mod tests {
name: "access_token".to_string(),
description: String::new(),
env_vars: vec!["DELEGATED_ACCESS_TOKEN".to_string()],
token_grant_owners: Vec::new(),
required: true,
stable_placeholder: false,
auth_style: "bearer".to_string(),
+11 -2
View File
@@ -967,7 +967,7 @@ pub(crate) async fn run_server(
shutdown_rx.clone(),
);
}
gateway_members::spawn_membership_worker(
let membership_worker = gateway_members::spawn_membership_worker(
state.clone(),
gateway_members::MEMBER_REFRESH_INTERVAL,
shutdown_rx.clone(),
@@ -987,11 +987,20 @@ pub(crate) async fn run_server(
}
let compute_cleanup = state.compute.cleanup_on_shutdown().await;
// Closing sessions redirect their supervisors through the ring. Let the
// membership worker finish pointing it at the remaining replicas first.
if let Some(worker) = membership_worker
&& tokio::time::timeout(gateway_members::SHUTDOWN_MEMBERSHIP_TIMEOUT, worker)
.await
.is_err()
{
warn!("Gateway membership worker did not finish leaving the ring before session shutdown");
}
// A stopped supervisor may still have a detached task deleting its owner
// record. Drain it even when compute cleanup failed before exiting Tokio.
let session_cleanup = state
.supervisor_sessions
.shutdown(Duration::from_secs(10))
.shutdown(supervisor_session::SUPERVISOR_SESSION_SHUTDOWN_TIMEOUT)
.await;
if let Err(err) = &session_cleanup {
warn!(error = %err, "Gateway supervisor session cleanup incomplete");
@@ -777,14 +777,10 @@ fn validate_application_authorization<B>(
let value = value
.to_str()
.map_err(|_| ServiceRouteError::invalid_request())?;
let Some((scheme, credential)) = value.split_once(' ') else {
return Err(ServiceRouteError::invalid_request());
};
let credential = credential.trim_start_matches(' ');
if !scheme.eq_ignore_ascii_case("bearer") || !is_bearer_token68(credential) {
return Err(ServiceRouteError::invalid_request());
match openshell_core::auth::strip_bearer_scheme(value) {
Some(credential) if is_bearer_token68(credential) => Ok(()),
_ => Err(ServiceRouteError::invalid_request()),
}
Ok(())
}
fn is_bearer_token68(value: &str) -> bool {
+6 -2
View File
@@ -141,10 +141,14 @@ mod tests {
// the set of shared message and enum types remains unchanged.
// SessionRedirect and SupervisorHello.redirected are supervisor control
// traffic and are never stored.
// Token-grant owner fields add gateway-derived metadata to endpoints and
// profile credentials reachable from stored policies and provider profiles.
// Legacy payloads decode empty owners; the gateway rebuilds their authority
// from effective policy rather than trusting persisted owner stamps.
const PUBLIC_RPC_SCHEMA_SHA256: &str =
"2adec5d69e65dff0fc64ee1efe847b15839d172234a17dcb3910d77f4925da09";
"e2c1a8b73eed91cfed798faa22aa9cb54a8f9b7009295260f39e1417a75d7d9b";
const DURABLE_SCHEMA_SHA256: &str =
"67d74f0304ecfc3dc11135f11deda694dd64e5dde0dd0f6cbc1512bc519fa212";
"5281143e2825539af6148785c91f9df67f654eb74d215f57940af9314867a55f";
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
"761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3";
// Encoded with the schema that omitted stable_placeholder. Keep these
+454 -72
View File
@@ -41,6 +41,16 @@ use crate::supervisor_owner::{
const HEARTBEAT_INTERVAL_SECS: u32 = 15;
const OWNER_RENEW_TIMEOUT: Duration = Duration::from_secs(5);
const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10);
/// How long a stopping replica waits for a redirected supervisor to publish its
/// replacement session before marking the sandbox not ready.
const SHUTDOWN_HANDOFF_GRACE: Duration = Duration::from_secs(5);
/// How long gateway shutdown waits for supervisor session cleanup, including
/// handoff waits and the demotions that follow timed-out handoffs.
pub(crate) const SUPERVISOR_SESSION_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
// Timed-out handoffs demote one at a time after the grace expires, so leave at
// least as much budget after the grace as the grace itself.
const _: () =
assert!(SHUTDOWN_HANDOFF_GRACE.as_secs() * 2 <= SUPERVISOR_SESSION_SHUTDOWN_TIMEOUT.as_secs());
/// Initial backoff between session-availability polls in `wait_for_session`.
const SESSION_WAIT_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
/// Maximum backoff between session-availability polls in `wait_for_session`.
@@ -2219,12 +2229,13 @@ async fn establish_supervisor_session(
)
.await
{
state
.supervisor_sessions
.remove_if_current(&sandbox_id, &session_id);
if let Err(err) = owner_index.release_if_current(&owner_guard).await {
warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release owner after endpoint status initialization failure");
}
abandon_session_setup(
&state,
&owner_index,
&owner_guard,
"endpoint status initialization",
)
.await;
return Err(error);
}
if !state
@@ -2243,12 +2254,13 @@ async fn establish_supervisor_session(
&session_id,
provider_readiness,
) {
state
.supervisor_sessions
.remove_if_current(&sandbox_id, &session_id);
if let Err(err) = owner_index.release_if_current(&owner_guard).await {
warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release owner after provider readiness initialization failure");
}
abandon_session_setup(
&state,
&owner_index,
&owner_guard,
"provider readiness initialization",
)
.await;
return Err(error);
}
@@ -2265,12 +2277,7 @@ async fn establish_supervisor_session(
if tx.send(accepted).await.is_err() {
// Only evict ourselves — a faster reconnect may already have
// superseded this registration.
state
.supervisor_sessions
.remove_if_current(&sandbox_id, &session_id);
if let Err(err) = owner_index.release_if_current(&owner_guard).await {
warn!(sandbox_id = %sandbox_id, session_id = %session_id, error = %err, "supervisor session: failed to release owner after accept send failure");
}
abandon_session_setup(&state, &owner_index, &owner_guard, "accept send").await;
return Err(Status::internal("failed to send session accepted"));
}
@@ -2284,12 +2291,7 @@ async fn establish_supervisor_session(
// response forces a reconnect, which gives the state transition a
// fresh chance instead of leaving a healthy-looking supervisor tied
// to a sandbox that never reaches Ready.
state
.supervisor_sessions
.remove_if_current(&sandbox_id, &session_id);
if let Err(release_error) = owner_index.release_if_current(&owner_guard).await {
warn!(sandbox_id, session_id, error = %release_error, "supervisor session: failed to release owner after lifecycle persistence failure");
}
abandon_session_setup(&state, &owner_index, &owner_guard, "lifecycle persistence").await;
warn!(
sandbox_id = %sandbox_id,
session_id = %session_id,
@@ -2317,7 +2319,7 @@ async fn establish_supervisor_session(
tokio::spawn(async move {
let _session_lifetime = session_lifetime;
let mut owner_guard = owner_guard;
run_session_loop(
let exit = run_session_loop(
&state_clone,
&sandbox_id_clone,
&session_id,
@@ -2328,41 +2330,19 @@ async fn establish_supervisor_session(
&mut owner_guard,
)
.await;
let terminal_finalized = state_clone
.supervisor_sessions
.remove_if_current(&sandbox_id_clone, &session_id);
// Release only this exact ownership record. A newer supervisor session
// may already have published replacement ownership on another replica.
let owner_index = SupervisorOwnerIndex::new(state_clone.store.clone(), OWNER_TTL);
if let Err(err) = owner_index.release_if_current(&owner_guard).await {
warn!(sandbox_id = %sandbox_id_clone, session_id = %session_id, error = %err, "supervisor session: failed to release owner record");
}
if let Some(terminal_finalized) = terminal_finalized {
info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended");
state_clone
.telemetry
.sandbox_session_disconnected(&sandbox_id_clone);
tokio::spawn(
crate::grpc::policy::retry_endpoint_status_after_supervisor_disconnect(
Arc::clone(&state_clone),
sandbox_id_clone.clone(),
),
);
if let Err(err) = state_clone
.compute
.supervisor_session_disconnected(&sandbox_id_clone, terminal_finalized)
.await
{
warn!(
sandbox_id = %sandbox_id_clone,
session_id = %session_id,
error = %err,
"supervisor session: failed to mark sandbox disconnected"
);
}
} else {
info!(sandbox_id = %sandbox_id_clone, session_id = %session_id, "supervisor session: ended (already superseded)");
}
// Close both directions before cleanup. Queued messages, including a
// redirect, still reach the supervisor, and one without a redirect sees
// EOF at once instead of waiting out any handoff below.
drop(tx);
drop(inbound);
finish_supervisor_session(
&state_clone,
&sandbox_id_clone,
&session_id,
exit,
&owner_guard,
)
.await;
});
// Return the outbound stream.
@@ -2438,6 +2418,180 @@ pub async fn handle_finalize_main_process_exit(
))
}
/// Undo a session whose setup failed after the owner publish.
///
/// The publish may have replaced an owner whose replica kept the sandbox Ready
/// for a shutdown handoff. Without the demotion, a failed takeover would leave
/// the sandbox Ready with no session. The demotion is a no-op when another
/// session has already taken over.
async fn abandon_session_setup(
state: &ServerState,
owner_index: &SupervisorOwnerIndex,
owner_guard: &OwnerGuard,
failed_step: &str,
) {
let sandbox_id = owner_guard.sandbox_id.as_str();
let session_id = owner_guard.session_id.as_str();
state
.supervisor_sessions
.remove_if_current(sandbox_id, session_id);
if let Err(err) = owner_index.release_if_current(owner_guard).await {
warn!(sandbox_id, session_id, failed_step, error = %err, "supervisor session: failed to release owner after setup failure");
}
if let Err(err) = state
.compute
.supervisor_session_disconnected(sandbox_id, false)
.await
{
warn!(
sandbox_id,
session_id,
failed_step,
error = %err,
"supervisor session: failed to mark sandbox disconnected after setup failure"
);
}
}
/// Why a supervisor session loop ended.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SessionLoopExit {
/// This gateway is shutting down. `redirected` records whether a
/// `SessionRedirect` was queued for the supervisor.
GatewayShutdown { redirected: bool },
/// The session ended for any other reason.
Other,
}
/// Remove an ended session, release its ownership, and update the sandbox.
async fn finish_supervisor_session(
state: &Arc<ServerState>,
sandbox_id: &str,
session_id: &str,
exit: SessionLoopExit,
owner_guard: &OwnerGuard,
) {
let terminal_finalized = state
.supervisor_sessions
.remove_if_current(sandbox_id, session_id);
let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL);
// A redirected supervisor reconnects to another replica right away. Keep
// this replica's owner record until its replacement session supersedes
// it, so every replica, including a reconciliation sweep, keeps seeing an
// owner and the sandbox stays Ready through the handoff. Without a
// redirect the supervisor reconnects with backoff through the Service, so
// waiting would only delay the demotion.
if exit == (SessionLoopExit::GatewayShutdown { redirected: true })
&& terminal_finalized == Some(false)
&& sandbox_may_be_ready(state, sandbox_id).await
&& wait_for_replacement_owner(
&owner_index,
owner_guard,
tokio::time::Instant::now() + SHUTDOWN_HANDOFF_GRACE,
)
.await
{
info!(
sandbox_id,
session_id, "supervisor session: handed off to replacement session"
);
}
// Release only this exact ownership record. A newer supervisor session
// may already have published replacement ownership on another replica.
if let Err(err) = owner_index.release_if_current(owner_guard).await {
warn!(sandbox_id, session_id, error = %err, "supervisor session: failed to release owner record");
}
let Some(terminal_finalized) = terminal_finalized else {
info!(
sandbox_id,
session_id, "supervisor session: ended (already superseded)"
);
return;
};
info!(sandbox_id, session_id, "supervisor session: ended");
state.telemetry.sandbox_session_disconnected(sandbox_id);
tokio::spawn(
crate::grpc::policy::retry_endpoint_status_after_supervisor_disconnect(
Arc::clone(state),
sandbox_id.to_string(),
),
);
if let Err(err) = state
.compute
.supervisor_session_disconnected(sandbox_id, terminal_finalized)
.await
{
warn!(
sandbox_id,
session_id,
error = %err,
"supervisor session: failed to mark sandbox disconnected"
);
}
}
/// Whether a handoff could keep the sandbox Ready. Only a known non-Ready
/// phase rules it out; the disconnect that follows a handoff changes nothing
/// for such a sandbox, so waiting would only slow shutdown.
async fn sandbox_may_be_ready(state: &ServerState, sandbox_id: &str) -> bool {
match state.store.get_message::<Sandbox>(sandbox_id).await {
Ok(Some(sandbox)) => sandbox.phase() == SandboxPhase::Ready as i32,
Ok(None) => false,
Err(_) => true,
}
}
/// Wait until `deadline` for another session to supersede `owner_guard`.
///
/// Returns whether a fresh owner record from a different session appeared.
/// Store errors are logged and polling continues, so a transient error during
/// a roll does not cut the grace short. The deadline bounds every read.
async fn wait_for_replacement_owner(
owner_index: &SupervisorOwnerIndex,
owner_guard: &OwnerGuard,
deadline: tokio::time::Instant,
) -> bool {
let sandbox_id = owner_guard.sandbox_id.as_str();
tokio::time::timeout_at(deadline, async {
let mut backoff = SESSION_WAIT_INITIAL_BACKOFF;
loop {
match owner_index.read(sandbox_id).await {
Ok(Some(owner))
if owner.session_id != owner_guard.session_id && owner.is_fresh(OWNER_TTL) =>
{
return true;
}
Ok(_) => {}
Err(error) => warn!(
sandbox_id,
error = %error,
"supervisor session: failed to read owner during handoff; retrying"
),
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(SESSION_WAIT_MAX_BACKOFF);
}
})
.await
.unwrap_or(false)
}
/// Queue a shutdown redirect for a supervisor that supports one. Returns
/// whether a redirect is now queued for the supervisor.
fn queue_shutdown_redirect(
tx: &mpsc::Sender<GatewayMessage>,
supports_session_redirect: bool,
redirect: impl FnOnce() -> Option<SessionRedirect>,
) -> bool {
supports_session_redirect
&& redirect().is_some_and(|redirect| {
tx.try_send(GatewayMessage {
payload: Some(gateway_message::Payload::SessionRedirect(redirect)),
})
.is_ok()
})
}
#[allow(clippy::too_many_arguments)]
async fn run_session_loop(
state: &Arc<ServerState>,
@@ -2445,10 +2599,10 @@ async fn run_session_loop(
session_id: &str,
supports_session_redirect: bool,
tx: &mpsc::Sender<GatewayMessage>,
inbound: &mut tonic::Streaming<SupervisorMessage>,
inbound: &mut (impl tokio_stream::Stream<Item = Result<SupervisorMessage, Status>> + Unpin),
mut shutdown_rx: oneshot::Receiver<()>,
owner_guard: &mut OwnerGuard,
) {
) -> SessionLoopExit {
let mut gateway_shutdown = state.supervisor_sessions.shutdown.subscribe();
let heartbeat_interval = Duration::from_secs(u64::from(HEARTBEAT_INTERVAL_SECS));
let mut heartbeat_timer = tokio::time::interval(heartbeat_interval);
@@ -2459,21 +2613,17 @@ async fn run_session_loop(
tokio::select! {
() = async { let _ = gateway_shutdown.wait_for(|shutdown| *shutdown).await; } => {
info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: gateway shutting down");
if supports_session_redirect
&& let Some(redirect) = preferred_peer_redirect(state, sandbox_id)
{
let _ = tx.try_send(GatewayMessage {
payload: Some(gateway_message::Payload::SessionRedirect(redirect)),
});
}
break;
let redirected = queue_shutdown_redirect(tx, supports_session_redirect, || {
preferred_peer_redirect(state, sandbox_id)
});
return SessionLoopExit::GatewayShutdown { redirected };
}
_ = &mut shutdown_rx => {
info!(sandbox_id = %sandbox_id, session_id = %session_id, "supervisor session: superseded by reconnect, shutting down");
break;
}
msg = inbound.message() => {
match msg {
msg = tokio_stream::StreamExt::next(inbound) => {
match msg.transpose() {
Ok(Some(msg)) => {
if !handle_supervisor_message(state, sandbox_id, session_id, msg, owner_guard).await {
break;
@@ -2518,6 +2668,7 @@ async fn run_session_loop(
}
}
}
SessionLoopExit::Other
}
async fn handle_supervisor_message(
@@ -2650,6 +2801,237 @@ mod tests {
oneshot::channel::<()>().0
}
/// Store a Ready sandbox with a live local session that owns it.
async fn ready_sandbox_with_session(
state: &Arc<ServerState>,
sandbox_id: &str,
session_id: &str,
) -> OwnerGuard {
let mut sandbox = sandbox_record(sandbox_id, sandbox_id);
sandbox.set_phase(SandboxPhase::Ready as i32);
state.store.put_message(&sandbox).await.unwrap();
let (tx, _rx) = mpsc::channel(1);
state.supervisor_sessions.register(
sandbox_id.into(),
session_id.into(),
tx,
make_shutdown(),
);
SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL)
.publish(
sandbox_id,
session_id,
"old-instance",
1,
&state.replica_id,
"local://old",
)
.await
.unwrap()
}
async fn sandbox_phase(state: &Arc<ServerState>, sandbox_id: &str) -> SandboxPhase {
let sandbox = state
.store
.get_message::<Sandbox>(sandbox_id)
.await
.unwrap()
.unwrap();
SandboxPhase::try_from(sandbox.phase()).unwrap()
}
#[tokio::test]
async fn gateway_shutdown_reports_no_redirect_without_a_peer() {
let state = crate::grpc::test_support::test_server_state().await;
let mut owner_guard =
ready_sandbox_with_session(&state, "sb-no-peer", "session-no-peer").await;
state.gateway_shutting_down.store(true, Ordering::Release);
state.supervisor_sessions.shutdown.send_replace(true);
let (tx, mut rx) = mpsc::channel(4);
let mut inbound = futures::stream::pending::<Result<SupervisorMessage, Status>>();
let exit = run_session_loop(
&state,
"sb-no-peer",
"session-no-peer",
true,
&tx,
&mut inbound,
oneshot::channel().1,
&mut owner_guard,
)
.await;
assert_eq!(exit, SessionLoopExit::GatewayShutdown { redirected: false });
assert!(rx.try_recv().is_err(), "no redirect should be queued");
}
#[tokio::test]
async fn gateway_shutdown_without_redirect_demotes_without_waiting() {
let state = crate::grpc::test_support::test_server_state().await;
let owner_guard =
ready_sandbox_with_session(&state, "sb-unredirected", "session-unredirected").await;
tokio::time::timeout(
SHUTDOWN_HANDOFF_GRACE / 5,
finish_supervisor_session(
&state,
"sb-unredirected",
"session-unredirected",
SessionLoopExit::GatewayShutdown { redirected: false },
&owner_guard,
),
)
.await
.expect("a session without a redirect must not wait for a handoff");
assert_eq!(
sandbox_phase(&state, "sb-unredirected").await,
SandboxPhase::Provisioning
);
}
#[tokio::test]
async fn gateway_shutdown_with_redirect_keeps_sandbox_ready_through_handoff() {
let state = crate::grpc::test_support::test_server_state().await;
let owner_guard =
ready_sandbox_with_session(&state, "sb-redirected", "session-redirected").await;
let finish_state = Arc::clone(&state);
let finish = tokio::spawn(async move {
finish_supervisor_session(
&finish_state,
"sb-redirected",
"session-redirected",
SessionLoopExit::GatewayShutdown { redirected: true },
&owner_guard,
)
.await;
});
// While the handoff is pending, a reconciliation sweep on any replica
// must still see an owner, or it would demote the sandbox.
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(!finish.is_finished());
assert!(
state
.compute
.supervisor_session_ready("sb-redirected")
.await
.unwrap()
);
assert_eq!(
sandbox_phase(&state, "sb-redirected").await,
SandboxPhase::Ready
);
// The redirected supervisor reconnects with a higher epoch, which
// supersedes the record the stopping replica still holds.
SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL)
.publish(
"sb-redirected",
"replacement-session",
"old-instance",
2,
"gateway-b",
"http://gateway-b:8080",
)
.await
.unwrap();
tokio::time::timeout(SHUTDOWN_HANDOFF_GRACE, finish)
.await
.expect("handoff should finish once the replacement publishes")
.unwrap();
assert_eq!(
sandbox_phase(&state, "sb-redirected").await,
SandboxPhase::Ready
);
let owner = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL)
.read("sb-redirected")
.await
.unwrap()
.expect("the replacement owner record must survive the release");
assert_eq!(owner.session_id, "replacement-session");
}
#[tokio::test]
async fn gateway_shutdown_skips_handoff_for_sandbox_that_is_not_ready() {
let state = crate::grpc::test_support::test_server_state().await;
let owner_guard =
ready_sandbox_with_session(&state, "sb-not-ready", "session-not-ready").await;
let mut sandbox = state
.store
.get_message::<Sandbox>("sb-not-ready")
.await
.unwrap()
.unwrap();
sandbox.set_phase(SandboxPhase::Provisioning as i32);
state.store.put_message(&sandbox).await.unwrap();
tokio::time::timeout(
SHUTDOWN_HANDOFF_GRACE / 5,
finish_supervisor_session(
&state,
"sb-not-ready",
"session-not-ready",
SessionLoopExit::GatewayShutdown { redirected: true },
&owner_guard,
),
)
.await
.expect("a sandbox that is not Ready must not wait for a handoff");
}
#[tokio::test]
async fn replacement_owner_wait_ignores_own_record_and_gives_up_at_deadline() {
let state = crate::grpc::test_support::test_server_state().await;
let owner_guard =
ready_sandbox_with_session(&state, "sb-never-replaced", "session-own").await;
let owner_index = SupervisorOwnerIndex::new(state.store.clone(), OWNER_TTL);
let started = tokio::time::Instant::now();
assert!(
!wait_for_replacement_owner(
&owner_index,
&owner_guard,
started + Duration::from_millis(300),
)
.await
);
assert!(started.elapsed() < Duration::from_secs(1));
}
fn test_redirect() -> SessionRedirect {
SessionRedirect {
peer_endpoint: "https://gw-1:8443".to_string(),
owner_replica_id: "gw-1".to_string(),
}
}
#[test]
fn shutdown_redirect_is_queued_only_when_supported_available_and_sent() {
let (tx, mut rx) = mpsc::channel(1);
assert!(queue_shutdown_redirect(&tx, true, || Some(test_redirect())));
assert!(matches!(
rx.try_recv().unwrap().payload,
Some(gateway_message::Payload::SessionRedirect(_))
));
assert!(!queue_shutdown_redirect(&tx, true, || None));
assert!(!queue_shutdown_redirect(&tx, false, || {
panic!("an unsupported supervisor must not compute a redirect")
}));
// A full outbound channel drops the redirect.
tx.try_send(GatewayMessage::default()).unwrap();
assert!(!queue_shutdown_redirect(
&tx,
true,
|| Some(test_redirect())
));
}
#[tokio::test]
async fn shutdown_waits_for_owner_release_after_session_leaves_registry() {
let registry = Arc::new(SupervisorSessionRegistry::new());
@@ -223,29 +223,45 @@ egress_authorization := {
default allow_request = false
# Per-policy helper: true when this single policy has at least one endpoint
# matching the L4 request whose L7 rules also permit the specific request.
# Per-policy helper: endpoints whose selectors and L7 rules permit the request.
# Isolating the endpoint iteration inside a function avoids the regorus
# "duplicated definition of local variable" error that occurs when the
# outer `some name` iterates over multiple policies that share a host:port.
_policy_allows_l7(policy) if {
_policy_allowed_l7_endpoints(policy) := [ep |
some ep
ep := policy.endpoints[_]
endpoint_matches_l7_request(ep, input.network, input.request)
request_allowed_for_endpoint(input.request, ep)
}
]
# L7 request allowed if any matching L4 policy also allows the L7 request
# AND no deny rule blocks it. Deny rules take precedence over allow rules.
allow_request if {
# Forwarding and credential selection share endpoint admission. A deny rule
# on any matching endpoint removes every otherwise admitted endpoint.
_admitted_l7_endpoints := {ep |
some name
policy := data.network_policies[name]
endpoint_allowed(policy, input.network)
binary_allowed(policy, input.exec)
_policy_allows_l7(policy)
eps := _policy_allowed_l7_endpoints(policy)
ep := eps[_]
not deny_request
}
allow_request if {
count(_admitted_l7_endpoints) > 0
}
# Credentials require the admission of their own endpoint. A sibling endpoint's
# allow can authorize forwarding, but cannot authorize this endpoint's grant.
# Keep every admitting owner so overlapping allows retain union semantics, and
# preserve the global deny decision before any caller selects a credential.
# Missing owner metadata grants no credential authority. Sorting a set gives
# consumers a deterministic array without duplicate owner identifiers.
allowed_token_grant_owners := sort({owner |
some endpoint in _admitted_l7_endpoints
owner := object.get(endpoint, "token_grant_owner", "")
is_string(owner)
owner != ""
})
# --- L7 deny rules ---
#
# Deny rules are evaluated after allow rules and take precedence.
@@ -31,10 +31,14 @@ use openshell_ocsf::{
};
#[cfg(test)]
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tracing::{debug, warn};
#[cfg(test)]
mod token_grant_ownership_tests;
const CONNECTION_READ_AHEAD_BYTES: usize = 8 * 1024;
/// Context for L7 request policy evaluation.
@@ -67,6 +71,10 @@ pub struct L7EvalContext {
/// resolver. Used to reject a request if credentials change again before
/// its first upstream write.
pub(crate) provider_credential_revision: Option<u64>,
/// Installation that supplied an inspected request's dynamic credentials.
/// Repairs may retain the revision, so keep this identity across the grant
/// await and the final static-credential scoping before the upstream write.
pub(crate) provider_credential_installation_id: Option<String>,
pub(crate) body_classifier: Option<Arc<secrets::body::BodyCredentialClassifier>>,
/// Anonymous activity counter channel.
pub(crate) activity_tx: Option<ActivitySender>,
@@ -106,7 +114,11 @@ fn scoped_context_for_request(
// signing attempt fail closed before an upstream write.
scoped.secret_resolver = None;
scoped.body_classifier = None;
scoped.provider_credential_revision = None;
// A dynamic grant may already be installed on this request. Keep its
// generation pin across body buffering even without static authority.
if scoped.provider_credential_installation_id.is_none() {
scoped.provider_credential_revision = None;
}
return Some(scoped);
}
let credentials = ctx.provider_credentials.as_ref()?;
@@ -121,10 +133,88 @@ fn scoped_context_for_request(
fn credential_generation_guard(
ctx: &L7EvalContext,
) -> Option<crate::l7::rest::CredentialGenerationGuard<'_>> {
Some(crate::l7::rest::CredentialGenerationGuard::new(
ctx.provider_credentials.as_ref()?,
ctx.provider_credential_revision?,
))
Some(
crate::l7::rest::CredentialGenerationGuard::new(
ctx.provider_credentials.as_ref()?,
ctx.provider_credential_revision?,
)
.with_installation_id(ctx.provider_credential_installation_id.as_deref()),
)
}
/// Resolve a grant from the provider installation and policy owners admitted
/// for this request. Provider refresh is independent of tunnel lifetime: the
/// connection's original dynamic map cannot authorize a later request.
pub(crate) async fn prepare_inspected_request(
req: crate::l7::provider::L7Request,
ctx: &L7EvalContext,
engine: &TunnelPolicyEngine,
config: &L7EndpointConfig,
request_info: &L7RequestInfo,
) -> Result<(crate::l7::provider::L7Request, L7EvalContext)> {
let mut scoped = ctx.clone();
let snapshot = ctx
.provider_credentials
.as_ref()
.map(openshell_core::provider_credentials::ProviderCredentialState::snapshot);
if let Some(snapshot) = &snapshot {
scoped.provider_credential_revision = Some(snapshot.revision);
scoped.provider_credential_installation_id = Some(snapshot.installation_id.clone());
}
// Admission-gated injection reads credentials only from the pinned snapshot,
// so a context without one never acquires a grant.
let grant_snapshot = snapshot.as_deref().filter(|snapshot| {
snapshot
.dynamic_credentials
.values()
.any(|credential| credential.token_grant.is_some())
});
let req = if let Some(snapshot) = grant_snapshot {
// Middleware can replace an inspected body. Recompute owner admission for
// the body that will be sent, even when another endpoint allowed both the
// original and transformed requests. Request method/path/query are immutable.
let mut current_info = request_info.clone();
match config.protocol {
L7Protocol::Graphql => {
let body = req
.raw_header
.windows(4)
.position(|bytes| bytes == b"\r\n\r\n")
.and_then(|end| req.raw_header.get(end + 4..))
.unwrap_or_default();
current_info.graphql = Some(crate::l7::graphql::classify_request(&req, body));
}
L7Protocol::JsonRpc | L7Protocol::Mcp => {
let mut options = crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(config);
if let Some(revision) = request_info
.jsonrpc
.as_ref()
.and_then(|info| info.mcp_revision)
{
options = options.with_mcp_revision(revision);
}
current_info.jsonrpc = Some(
crate::l7::jsonrpc::inspect_buffered_jsonrpc_http_request(&req, options)?,
);
}
L7Protocol::Rest | L7Protocol::Websocket | L7Protocol::Sql => {}
}
let owners = admitted_token_grant_owners(engine, &scoped, &current_info)?;
crate::l7::token_grant_injection::inject_for_admitted_owners(
req, &scoped, snapshot, &owners,
)
.await?
} else {
req
};
if engine.is_stale() {
return Err(miette!("policy changed during token grant resolution"));
}
if let Some(guard) = credential_generation_guard(&scoped) {
guard.ensure_current()?;
}
let scoped = scoped_context_for_request(&scoped, &req).unwrap_or(scoped);
Ok((req, scoped))
}
fn request_authority_matches_endpoint(
@@ -536,6 +626,98 @@ pub(crate) async fn reject_body_credential<C: AsyncWrite + Unpin>(
client.flush().await.into_diagnostic()
}
struct InspectedForwarding<'a> {
config: &'a L7EndpointConfig,
engine: &'a TunnelPolicyEngine,
request_info: &'a L7RequestInfo,
request_id: &'a str,
response_chain: &'a [openshell_supervisor_middleware::ChainEntry],
websocket_middleware: bool,
observation_context: Option<&'a openshell_core::endpoint_status::EndpointObservationContext>,
}
// Every inspected HTTP relay enters here after policy and request middleware.
// Keep credential preparation and the guarded upstream write together so new
// protocol relays cannot accidentally forward without required authentication.
async fn forward_inspected_request<C, U>(
request: crate::l7::provider::L7Request,
client: &mut C,
upstream: &mut U,
ctx: &mut L7EvalContext,
forwarding: InspectedForwarding<'_>,
) -> Result<Option<RelayOutcome>>
where
C: AsyncRead + AsyncWrite + Unpin,
U: AsyncRead + AsyncWrite + Unpin,
{
let InspectedForwarding {
config,
engine,
request_info,
request_id,
response_chain,
websocket_middleware,
observation_context,
} = forwarding;
let (request, grant_ctx) =
match prepare_inspected_request(request, ctx, engine, config, request_info).await {
Ok(prepared) => prepared,
Err(error) => {
warn!(error = %error, "Token grant failed before forwarding");
write_bad_gateway_response(client).await?;
return Ok(None);
}
};
*ctx = grant_ctx;
let observer = EndpointObserver::begin_captured(
ctx.endpoint_observation_tx.as_ref(),
config,
observation_context,
ctx.provider_credential_revision,
Some(engine.generation_guard()),
);
relay_http_request_with_credential_rejection_observed(
&request,
client,
upstream,
crate::l7::rest::RelayRequestOptions {
resolver: ctx.secret_resolver.as_deref(),
body_classifier: ctx.body_classifier.as_deref(),
mcp_request_validation: (config.protocol == L7Protocol::Mcp).then_some(
crate::l7::rest::McpRequestValidation {
config,
ctx,
redacted_target: &request_info.target,
},
),
credential_generation: credential_generation_guard(ctx),
generation_guard: Some(engine.generation_guard()),
websocket_extensions: websocket_extension_mode(config, websocket_middleware),
request_body_credential_rewrite: config.protocol == L7Protocol::Rest
&& config.request_body_credential_rewrite,
deny_uninspected_credentials: config
.deny_uninspected_body_credentials(ctx.secret_resolver.is_some()),
credential_signing: config.credential_signing,
signing_service: &config.signing_service,
signing_region: &config.signing_region,
host: &ctx.host,
port: ctx.port,
},
ctx,
Some(http_response_middleware_relay(
&request,
ctx,
"https",
request_id,
response_chain,
engine.middleware_runner(),
Some(engine.generation_guard()),
)),
observer.as_ref(),
)
.await
}
async fn relay_http_request_with_credential_rejection<C, U>(
request: &crate::l7::provider::L7Request,
client: &mut C,
@@ -1257,18 +1439,6 @@ where
return Ok(());
}
};
let scoped_ctx = scoped_context_for_request(ctx, &req);
let ctx = scoped_ctx.as_ref().unwrap_or(ctx);
// Credential scoping can acquire a newer revision after middleware.
// Bind its actual snapshot to the original authority, never a fresh
// epoch; the earlier handle served only pre-forward local decisions.
let observer = EndpointObserver::begin_captured(
ctx.endpoint_observation_tx.as_ref(),
config,
observation_context.as_ref(),
ctx.provider_credential_revision,
Some(engine.generation_guard()),
);
let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() {
let preflight = websocket_middleware_preflight(
&req,
@@ -1302,49 +1472,25 @@ where
} else {
None
};
let outcome_result = relay_http_request_with_credential_rejection_observed(
&req,
let query_params = req.query_params.clone();
let mut forwarding_ctx = ctx.clone();
let outcome_result = forward_inspected_request(
req,
client,
upstream,
crate::l7::rest::RelayRequestOptions {
resolver: ctx.secret_resolver.as_deref(),
body_classifier: ctx.body_classifier.as_deref(),
mcp_request_validation: (config.protocol == L7Protocol::Mcp).then_some(
crate::l7::rest::McpRequestValidation {
config,
ctx,
redacted_target: &redacted_target,
},
),
credential_generation: credential_generation_guard(ctx),
generation_guard: Some(engine.generation_guard()),
websocket_extensions: websocket_extension_mode(
config,
middleware_session.is_some(),
),
request_body_credential_rewrite: config.protocol == L7Protocol::Rest
&& config.request_body_credential_rewrite,
deny_uninspected_credentials: config
.deny_uninspected_body_credentials(ctx.secret_resolver.is_some()),
credential_signing: config.credential_signing,
signing_service: &config.signing_service,
signing_region: &config.signing_region,
host: &ctx.host,
port: ctx.port,
&mut forwarding_ctx,
InspectedForwarding {
config,
engine: &engine,
request_info: &request_info,
request_id: &request_id,
response_chain: &response_chain,
websocket_middleware: middleware_session.is_some(),
observation_context: observation_context.as_ref(),
},
ctx,
Some(http_response_middleware_relay(
&req,
ctx,
"https",
&request_id,
&response_chain,
engine.middleware_runner(),
Some(engine.generation_guard()),
)),
observer.as_ref(),
)
.await;
let ctx = &forwarding_ctx;
let outcome_result = match outcome_result {
Ok(Some(outcome)) => Ok(outcome),
Ok(None) => {
@@ -1413,7 +1559,7 @@ where
ctx,
websocket_request,
&redacted_target,
&req.query_params,
&query_params,
Some(&engine),
);
options.websocket.permessage_deflate = websocket_permessage_deflate;
@@ -2068,60 +2214,25 @@ where
} else {
None
};
let req_with_auth =
match crate::l7::token_grant_injection::inject_if_needed(req, ctx).await {
Ok(req) => req,
Err(e) => {
warn!(
host = %ctx.host,
port = ctx.port,
error = %e,
"Token grant failed in L7 relay"
);
write_bad_gateway_response(client).await?;
return Ok(());
}
};
let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth);
let ctx = scoped_ctx.as_ref().unwrap_or(ctx);
// Forward request to upstream and relay response
let outcome_result = relay_http_request_with_credential_rejection(
&req_with_auth,
let query_params = req.query_params.clone();
let mut forwarding_ctx = ctx.clone();
let outcome_result = forward_inspected_request(
req,
client,
upstream,
crate::l7::rest::RelayRequestOptions {
resolver: ctx.secret_resolver.as_deref(),
body_classifier: ctx.body_classifier.as_deref(),
mcp_request_validation: None,
credential_generation: credential_generation_guard(ctx),
generation_guard: Some(engine.generation_guard()),
websocket_extensions: websocket_extension_mode(
config,
middleware_session.is_some(),
),
request_body_credential_rewrite: config.protocol == L7Protocol::Rest
&& config.request_body_credential_rewrite,
deny_uninspected_credentials: config
.deny_uninspected_body_credentials(ctx.secret_resolver.is_some()),
credential_signing: config.credential_signing,
signing_service: &config.signing_service,
signing_region: &config.signing_region,
host: &ctx.host,
port: ctx.port,
&mut forwarding_ctx,
InspectedForwarding {
config,
engine,
request_info: &request_info,
request_id: &request_id,
response_chain: &response_chain,
websocket_middleware: middleware_session.is_some(),
observation_context: None,
},
ctx,
Some(http_response_middleware_relay(
&req_with_auth,
ctx,
"https",
&request_id,
&response_chain,
engine.middleware_runner(),
Some(engine.generation_guard()),
)),
)
.await;
let ctx = &forwarding_ctx;
let outcome_result = match outcome_result {
Ok(Some(outcome)) => Ok(outcome),
Ok(None) => {
@@ -2174,7 +2285,7 @@ where
ctx,
websocket_request,
&redacted_target,
&req_with_auth.query_params,
&query_params,
Some(engine),
);
options.websocket.permessage_deflate = websocket_permessage_deflate;
@@ -2502,49 +2613,21 @@ where
return Ok(());
}
};
let scoped_ctx = scoped_context_for_request(ctx, &req);
let ctx = scoped_ctx.as_ref().unwrap_or(ctx);
// The outgoing resolver and revision are one snapshot. Rebind using
// the original authority so a newly scoped provider revision cannot
// publish its result under the earlier inventory.
let observer = EndpointObserver::begin_captured(
ctx.endpoint_observation_tx.as_ref(),
config,
observation_context.as_ref(),
ctx.provider_credential_revision,
Some(engine.generation_guard()),
);
// Policy inspects client request bodies. Response bodies and SSE
// messages remain opaque and are relayed without MCP inspection.
let Some(outcome) = relay_http_request_with_credential_rejection_observed(
&req,
let mut forwarding_ctx = ctx.clone();
let Some(outcome) = forward_inspected_request(
req,
client,
upstream,
crate::l7::rest::RelayRequestOptions {
resolver: ctx.secret_resolver.as_deref(),
body_classifier: ctx.body_classifier.as_deref(),
mcp_request_validation: (config.protocol == L7Protocol::Mcp).then_some(
crate::l7::rest::McpRequestValidation {
config,
ctx,
redacted_target: &redacted_target,
},
),
credential_generation: credential_generation_guard(ctx),
generation_guard: Some(engine.generation_guard()),
..Default::default()
&mut forwarding_ctx,
InspectedForwarding {
config,
engine,
request_info: &request_info,
request_id: &request_id,
response_chain: &response_chain,
websocket_middleware: false,
observation_context: observation_context.as_ref(),
},
ctx,
Some(http_response_middleware_relay(
&req,
ctx,
"https",
&request_id,
&response_chain,
engine.middleware_runner(),
Some(engine.generation_guard()),
)),
observer.as_ref(),
)
.await?
else {
@@ -2804,29 +2887,21 @@ where
return Ok(());
}
};
let scoped_ctx = scoped_context_for_request(ctx, &req);
let ctx = scoped_ctx.as_ref().unwrap_or(ctx);
let Some(outcome) = relay_http_request_with_credential_rejection(
&req,
let mut forwarding_ctx = ctx.clone();
let Some(outcome) = forward_inspected_request(
req,
client,
upstream,
crate::l7::rest::RelayRequestOptions {
resolver: ctx.secret_resolver.as_deref(),
body_classifier: ctx.body_classifier.as_deref(),
credential_generation: credential_generation_guard(ctx),
generation_guard: Some(engine.generation_guard()),
..Default::default()
&mut forwarding_ctx,
InspectedForwarding {
config,
engine,
request_info: &request_info,
request_id: &request_id,
response_chain: &response_chain,
websocket_middleware: false,
observation_context: None,
},
ctx,
Some(http_response_middleware_relay(
&req,
ctx,
"https",
&request_id,
&response_chain,
engine.middleware_runner(),
Some(engine.generation_guard()),
)),
)
.await?
else {
@@ -3248,6 +3323,85 @@ fn jsonrpc_policy_input(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> serde_
})
}
/// A batch may be allowed by several endpoints, but one credential owner must
/// admit every member before its token can authenticate the combined request.
fn admitted_token_grant_owners(
engine: &TunnelPolicyEngine,
ctx: &L7EvalContext,
request: &L7RequestInfo,
) -> Result<HashSet<String>> {
if let Some(jsonrpc) = &request.jsonrpc
&& jsonrpc.is_batch
&& !jsonrpc.calls.is_empty()
{
let mut owners = if jsonrpc.has_response {
Some(admitted_token_grant_owners_once(engine, ctx, request)?)
} else {
None
};
for call in &jsonrpc.calls {
let admitted = admitted_token_grant_owners_once(
engine,
ctx,
&jsonrpc_request_for_call(request, call),
)?;
if let Some(owners) = &mut owners {
owners.retain(|owner| admitted.contains(owner));
} else {
owners = Some(admitted);
}
}
return Ok(owners.unwrap_or_default());
}
admitted_token_grant_owners_once(engine, ctx, request)
}
fn admitted_token_grant_owners_once(
engine: &TunnelPolicyEngine,
ctx: &L7EvalContext,
request: &L7RequestInfo,
) -> Result<HashSet<String>> {
if engine.is_stale() {
return Err(miette!("policy changed before token grant selection"));
}
let mut engine = engine
.engine()
.lock()
.map_err(|_| miette!("OPA engine lock poisoned"))?;
crate::opa::set_regorus_input(&mut engine, l7_request_policy_input(ctx, request))?;
let owners = engine
.eval_rule("data.openshell.sandbox.allowed_token_grant_owners".into())
.map_err(|error| miette!("{error}"))?;
let regorus::Value::Array(owners) = owners else {
return Err(miette!("invalid credential owner admission result"));
};
owners
.iter()
.map(|owner| match owner {
regorus::Value::String(owner) => Ok(owner.to_string()),
_ => Err(miette!("invalid credential owner identity")),
})
.collect()
}
fn l7_request_policy_input(ctx: &L7EvalContext, request: &L7RequestInfo) -> serde_json::Value {
serde_json::json!({
"network": { "host": ctx.host, "port": ctx.port },
"exec": {
"path": ctx.binary_path,
"ancestors": ctx.ancestors,
"cmdline_paths": ctx.cmdline_paths,
},
"request": {
"method": request.action,
"path": request.target,
"query_params": request.query_params.clone(),
"graphql": request.graphql.clone(),
"jsonrpc": request.jsonrpc.as_ref().map(jsonrpc_policy_input),
}
})
}
fn evaluate_l7_request_once(
engine: &TunnelPolicyEngine,
ctx: &L7EvalContext,
@@ -3261,24 +3415,7 @@ fn evaluate_l7_request_once(
));
}
let input = serde_json::json!({
"network": {
"host": ctx.host,
"port": ctx.port,
},
"exec": {
"path": ctx.binary_path,
"ancestors": ctx.ancestors,
"cmdline_paths": ctx.cmdline_paths,
},
"request": {
"method": request.action,
"path": request.target,
"query_params": request.query_params.clone(),
"graphql": request.graphql.clone(),
"jsonrpc": request.jsonrpc.as_ref().map(jsonrpc_policy_input),
}
});
let input = l7_request_policy_input(ctx, request);
let mut engine = engine
.engine()
@@ -4189,6 +4326,7 @@ network_policies:
- host: api.example.test
port: 8080
protocol: rest
token_grant_owner: test-owner
enforcement: enforce
rules:
- allow:
@@ -4236,6 +4374,7 @@ network_policies:
cmdline_paths: vec![],
secret_resolver: None,
dynamic_credentials: Some(fixture.dynamic_credentials()),
provider_credentials: Some(fixture.provider_credentials()),
token_grant_resolver: Some(fixture.resolver()),
..Default::default()
};
@@ -4269,6 +4408,7 @@ network_policies:
}
};
ctx.dynamic_credentials = Some(fixture.dynamic_credentials());
ctx.provider_credentials = Some(fixture.provider_credentials());
ctx.token_grant_resolver = Some(fixture.resolver());
(config, tunnel_engine, ctx, fixture)
@@ -4564,6 +4704,7 @@ network_policies:
- host: api.example.test
port: 8080
protocol: rest
token_grant_owner: test-owner
enforcement: enforce
rules:
- allow:
@@ -4603,6 +4744,7 @@ network_policies:
policy_name: "rest_api".into(),
binary_path: "/usr/bin/curl".into(),
dynamic_credentials: Some(fixture.dynamic_credentials()),
provider_credentials: Some(fixture.provider_credentials()),
token_grant_resolver: Some(fixture.resolver()),
..Default::default()
};
@@ -5064,7 +5206,7 @@ network_policies:
}
async fn assert_multiple_grants_tls_relay(identity_result: std::result::Result<&str, &str>) {
let (config, tunnel_engine, ctx, fixture) =
let (config, tunnel_engine, mut 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";
@@ -5073,6 +5215,8 @@ network_policies:
identity.auth_style = "header".into();
identity.header_name = "X-Workload-Jwt".into();
fixture.add_credential(identity_key, identity, identity_result);
// The context's snapshot predates the second credential; pin a new one.
ctx.provider_credentials = Some(fixture.provider_credentials());
// 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;
File diff suppressed because it is too large Load Diff
@@ -801,6 +801,7 @@ pub(crate) struct McpRequestValidation<'a> {
pub(crate) struct CredentialGenerationGuard<'a> {
state: &'a openshell_core::provider_credentials::ProviderCredentialState,
revision: u64,
installation_id: Option<&'a str>,
}
/// Typed marker for provider credential material that cannot be used.
@@ -833,11 +834,28 @@ impl<'a> CredentialGenerationGuard<'a> {
state: &'a openshell_core::provider_credentials::ProviderCredentialState,
revision: u64,
) -> Self {
Self { state, revision }
Self {
state,
revision,
installation_id: None,
}
}
/// Bind dynamic material to its exact installation, including a repair
/// that retains the provider revision. Static-only callers retain their
/// existing revision guard.
pub(crate) fn with_installation_id(mut self, installation_id: Option<&'a str>) -> Self {
self.installation_id = installation_id;
self
}
pub(crate) fn ensure_current(self) -> Result<()> {
if self.state.revision() == self.revision {
let snapshot = self.state.snapshot();
if snapshot.revision == self.revision
&& self
.installation_id
.is_none_or(|id| id == snapshot.installation_id)
{
Ok(())
} else {
Err(miette::Report::new(CredentialUnavailableError::new(
@@ -6,13 +6,15 @@
#[cfg(test)]
mod multiple_grants;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use miette::{Result, miette};
use openshell_core::dynamic_credential_key::{credential_identity, endpoint_selector};
use openshell_core::proto::{ProviderCredentialTokenGrant, ProviderProfileCredential};
use openshell_core::provider_credentials::ProviderCredentialSnapshot;
use openshell_ocsf::{
ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, SeverityId,
StatusId, Url as OcsfUrl, ctx::ctx as ocsf_ctx, ocsf_emit,
@@ -83,20 +85,70 @@ pub async fn inject_if_needed(req: L7Request, ctx: &L7EvalContext) -> Result<L7R
let credentials = dynamic_credentials
.read()
.map_err(|_| miette!("dynamic credential snapshot unavailable"))?;
let candidates = credentials
.iter()
.filter_map(|(key, cred)| {
let score =
dynamic_credential_key_match_score(key, &ctx.host, ctx.port, request_path)?;
cred.token_grant
.is_some()
.then(|| (score, key.clone(), cred.clone()))
})
.collect();
select_token_grants(candidates)?
select_token_grants(token_grant_candidates(
&credentials,
ctx,
request_path,
None,
)?)?
}
None => Vec::new(),
};
inject_selected(req, ctx, credentials).await
}
/// Inject only grants owned by endpoints that admitted the inspected request.
/// An empty owner set permits no acquisition, including audit-only forwarding.
/// L4-only forwarding continues to use `inject_if_needed` with its existing
/// endpoint-selector contract; it has no per-request L7 admission decision.
///
/// Credentials come from the pinned `snapshot`, and each selected key is scoped
/// to its revision and installation after selection so the one-grant-per-header
/// ambiguity check still compares the original credential identities.
pub(super) async fn inject_for_admitted_owners(
req: L7Request,
ctx: &L7EvalContext,
snapshot: &ProviderCredentialSnapshot,
admitted_owners: &HashSet<String>,
) -> Result<L7Request> {
let request_path = req.target.split('?').next().unwrap_or(req.target.as_str());
if snapshot
.dynamic_credentials
.iter()
.any(|(key, credential)| {
credential.token_grant.is_some()
&& credential.token_grant_owners.is_empty()
&& dynamic_credential_key_match_score(key, &ctx.host, ctx.port, request_path)
.is_some()
})
{
return Err(miette!(
"dynamic token grant is missing endpoint ownership metadata; upgrade the gateway before the supervisor"
));
}
let candidates = token_grant_candidates(
&snapshot.dynamic_credentials,
ctx,
request_path,
Some(admitted_owners),
)?;
let credentials = select_token_grants(candidates)?
.into_iter()
.map(|(key, cred)| (snapshot.scoped_key(&key), cred))
.collect();
inject_selected(req, ctx, credentials).await
}
/// Acquires every selected grant and rewrites the request headers.
///
/// An empty selection forwards the request unchanged. Any failed acquisition
/// returns an error before any header is written.
async fn inject_selected(
req: L7Request,
ctx: &L7EvalContext,
credentials: Vec<(String, ProviderProfileCredential)>,
) -> Result<L7Request> {
let request_path = req.target.split('?').next().unwrap_or(req.target.as_str());
if credentials.is_empty() {
return Ok(req);
}
@@ -186,13 +238,14 @@ fn select_token_grants(
candidates.sort_unstable_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));
let mut selected = BTreeMap::<String, (u32, String, ProviderProfileCredential)>::new();
for (score, key, credential) in candidates {
let identity =
credential_identity(&key).ok_or_else(|| miette!("invalid dynamic credential key"))?;
let header = token_grant_header_name(&credential)?.to_ascii_lowercase();
if let Some((selected_score, selected_key, _)) = selected.get(&header) {
// Equal-specificity selectors of the same credential can overlap. A
// different credential cannot win a tie for the same protected header.
if score == *selected_score
&& key.rsplit('\t').next() != selected_key.rsplit('\t').next()
{
let selected_identity = credential_identity(selected_key);
if score == *selected_score && Some(identity) != selected_identity {
return Err(miette!("ambiguous dynamic token grants for one header"));
}
continue;
@@ -205,6 +258,39 @@ fn select_token_grants(
.collect())
}
/// Collects token-grant credentials whose endpoint binding matches the request.
///
/// With `admitted_owners`, a credential is a candidate only when one of its
/// owners admitted the request. Filtering happens before specificity ranking, so
/// a narrower sibling cannot lend its grant to an allow from another endpoint.
fn token_grant_candidates(
credentials: &HashMap<String, ProviderProfileCredential>,
ctx: &L7EvalContext,
request_path: &str,
admitted_owners: Option<&HashSet<String>>,
) -> Result<Vec<(u32, String, ProviderProfileCredential)>> {
credentials
.iter()
.filter_map(|(key, cred)| {
if cred.token_grant.is_none()
|| admitted_owners.is_some_and(|owners| {
!cred
.token_grant_owners
.iter()
.any(|owner| owners.contains(owner))
})
{
return None;
}
if endpoint_selector(key).is_none() {
return Some(Err(miette!("invalid dynamic credential endpoint key")));
}
let score = dynamic_credential_key_match_score(key, &ctx.host, ctx.port, request_path)?;
Some(Ok((score, key.clone(), cred.clone())))
})
.collect()
}
fn ocsf_message_field(value: &str) -> String {
value
.chars()
@@ -246,25 +332,21 @@ fn dynamic_credential_key_match_score(
port: u16,
request_path: &str,
) -> Option<u32> {
let mut parts = key.splitn(4, '\t');
let endpoint_host = parts.next()?;
let endpoint_port = parts.next()?;
let endpoint_path = parts.next()?;
let _provider_key = parts.next()?;
let selector = endpoint_selector(key)?;
if endpoint_port.parse::<u16>().ok() != Some(port) {
if selector.port.parse::<u16>().ok() != Some(port) {
return None;
}
if !openshell_core::host_pattern::host_matches(endpoint_host, host).unwrap_or(false)
|| !crate::l7::endpoint_path_matches(endpoint_path, request_path)
if !openshell_core::host_pattern::host_matches(selector.host, host).unwrap_or(false)
|| !crate::l7::endpoint_path_matches(selector.path, request_path)
{
return None;
}
Some(
host_pattern_specificity(&endpoint_host.to_ascii_lowercase())
+ endpoint_path_specificity(endpoint_path),
host_pattern_specificity(&selector.host.to_ascii_lowercase())
+ endpoint_path_specificity(selector.path),
)
}
@@ -446,9 +528,20 @@ pub mod test_support {
resolver: Arc<dyn TokenGrantResolver>,
requests: Arc<Mutex<Vec<OwnedTokenGrantRequest>>>,
responses: Arc<Mutex<HashMap<String, std::result::Result<String, String>>>>,
/// Revision-scoped key -> installed key, so assertions can name the key a
/// test installed even when injection requests its scoped form.
scoped_keys: Mutex<HashMap<String, String>>,
}
impl TokenGrantTestFixture {
fn installed_key(&self, requested: &str) -> String {
self.scoped_keys
.lock()
.unwrap()
.get(requested)
.map_or_else(|| requested.to_string(), Clone::clone)
}
pub fn success(key: &str, token: &str) -> Self {
Self::new(key, Ok(token))
}
@@ -488,6 +581,7 @@ pub mod test_support {
dynamic_credentials.insert(
key.to_string(),
ProviderProfileCredential {
token_grant_owners: vec!["test-owner".to_string()],
name: "access_token".to_string(),
auth_style: "bearer".to_string(),
header_name: "Authorization".to_string(),
@@ -501,6 +595,7 @@ pub mod test_support {
resolver,
requests,
responses,
scoped_keys: Mutex::new(HashMap::new()),
}
}
@@ -525,7 +620,7 @@ pub mod test_support {
assert_eq!(
requests
.iter()
.map(|r| r.provider_key.as_str())
.map(|r| self.installed_key(&r.provider_key))
.collect::<Vec<_>>(),
expected
);
@@ -539,7 +634,7 @@ pub mod test_support {
let requests = self.requests.lock().unwrap();
let request = requests
.iter()
.find(|r| r.provider_key == key)
.find(|r| self.installed_key(&r.provider_key) == key)
.expect("grant was requested");
assert_eq!(request.token_endpoint, grant.token_endpoint);
assert_eq!(request.jwt_svid_audience, grant.jwt_svid_audience);
@@ -569,6 +664,32 @@ pub mod test_support {
self.resolver.clone()
}
/// Installs this fixture's credentials as a pinned provider-credential
/// snapshot. Admission-gated injection requests grants by revision-scoped
/// key, so each scripted response is also registered under that key.
pub fn provider_credentials(
&self,
) -> openshell_core::provider_credentials::ProviderCredentialState {
let credentials = self.dynamic_credentials.read().unwrap().clone();
let state =
openshell_core::provider_credentials::ProviderCredentialState::from_environment(
1,
HashMap::new(),
HashMap::new(),
credentials.clone(),
);
let snapshot = state.snapshot();
let mut responses = self.responses.lock().unwrap();
for key in credentials.keys() {
let scoped = snapshot.scoped_key(key);
if let Some(response) = responses.get(key).cloned() {
responses.insert(scoped.clone(), response);
}
self.scoped_keys.lock().unwrap().insert(scoped, key.clone());
}
state
}
pub fn assert_no_requests(&self) {
let requests = self
.requests
@@ -585,7 +706,10 @@ pub mod test_support {
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.provider_key, expected_provider_key);
assert_eq!(
self.installed_key(&request.provider_key),
expected_provider_key
);
assert_eq!(request.token_endpoint, "https://auth.example.com/token");
assert_eq!(request.jwt_svid_audience, "https://auth.example.com");
assert_eq!(
@@ -610,7 +734,10 @@ pub mod test_support {
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.provider_key, expected_provider_key);
assert_eq!(
self.installed_key(&request.provider_key),
expected_provider_key
);
assert_eq!(request.token_endpoint, "https://auth.example.com/token");
assert_eq!(request.jwt_svid_audience, "https://auth.example.com");
assert_eq!(
@@ -957,7 +1084,7 @@ mod tests {
let req = L7Request {
action: "GET".to_string(),
target: "/v1/projects".to_string(),
query_params: std::collections::HashMap::new(),
query_params: HashMap::new(),
raw_header: b"GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(),
body_length: BodyLength::None,
};
@@ -995,7 +1122,7 @@ mod tests {
let req = L7Request {
action: "GET".to_string(),
target: "/v1/projects".to_string(),
query_params: std::collections::HashMap::new(),
query_params: HashMap::new(),
raw_header: b"GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(),
body_length: BodyLength::None,
};
@@ -1034,7 +1161,7 @@ mod tests {
let req = L7Request {
action: "GET".to_string(),
target: "/v1/projects".to_string(),
query_params: std::collections::HashMap::new(),
query_params: HashMap::new(),
raw_header: b"GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(),
body_length: BodyLength::None,
};
@@ -50,6 +50,22 @@ fn fixture(
(fixture, ctx)
}
#[tokio::test]
async fn malformed_endpoint_key_fails_before_acquisition() {
let (fixture, ctx) = fixture(Ok("identity-token"));
{
let credentials = fixture.dynamic_credentials();
let mut credentials = credentials.write().unwrap();
let credential = credentials.remove(SERVICE).unwrap();
credentials.insert(
"api.example.com\t443\t/v1/**\towner\tprovider:a\tother:service".into(),
credential,
);
}
assert!(inject_if_needed(request(), &ctx).await.is_err());
fixture.assert_no_requests();
}
#[tokio::test]
async fn injects_independent_grants_and_replaces_all_protected_headers() {
let (fixture, ctx) = fixture(Ok("identity-token"));
@@ -255,3 +271,63 @@ async fn concurrent_requests_keep_credential_snapshots_separate() {
first.assert_requested_keys(&[SERVICE, IDENTITY]);
second.assert_requested_keys(&[SERVICE, IDENTITY]);
}
#[tokio::test]
async fn admitted_owners_select_grants_independently_per_header() {
// The service grant keeps the fixture owner; the identity grant belongs to a
// different endpoint. Each case lists the owners that admitted the request and
// whether the service and identity headers are replaced.
let cases: [(&[&str], bool, bool); 4] = [
(&[], false, false),
(&["test-owner"], true, false),
(&["identity-owner"], false, true),
(&["test-owner", "identity-owner"], true, true),
];
for (admitted, service, identity) in cases {
let (fixture, mut ctx) = fixture(Ok("identity-token"));
fixture
.dynamic_credentials()
.write()
.unwrap()
.get_mut(IDENTITY)
.unwrap()
.token_grant_owners = vec!["identity-owner".into()];
let state = fixture.provider_credentials();
ctx.provider_credentials = Some(state.clone());
let owners = admitted
.iter()
.map(|owner| (*owner).to_string())
.collect::<HashSet<_>>();
let rewritten = inject_for_admitted_owners(request(), &ctx, &state.snapshot(), &owners)
.await
.unwrap();
let bytes = String::from_utf8(rewritten.raw_header).unwrap();
// A grant whose owner did not admit the request is never acquired, and its
// header keeps the workload's own value.
assert_eq!(
bytes.contains("Authorization: Bearer service-token\r\n"),
service,
"service header for {admitted:?}"
);
assert_eq!(
bytes.contains("Authorization: Bearer agent-token\r\n"),
!service,
"workload Authorization for {admitted:?}"
);
assert_eq!(
bytes.contains("X-Workload-Jwt: identity-token\r\n"),
identity,
"identity header for {admitted:?}"
);
let mut expected = Vec::new();
if service {
expected.push(SERVICE);
}
if identity {
expected.push(IDENTITY);
}
fixture.assert_requested_keys(&expected);
}
}
@@ -2425,6 +2425,12 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St
if e.provider_credentialed {
ep["provider_credentialed"] = true.into();
}
if !e.token_grant_owner.is_empty() {
// Preserve the gateway's endpoint identity so admission
// can select its grants without reconstructing ownership
// from overlapping host and path selectors.
ep["token_grant_owner"] = e.token_grant_owner.clone().into();
}
if is_mcp_protocol(&e.protocol) {
// Derive endpoint identity from the policy endpoint while
// it is still available. Request handling carries this
@@ -5448,6 +5454,195 @@ process:
val == regorus::Value::from(true)
}
fn token_grant_owner_engine(policies: serde_json::Value) -> OpaEngine {
// Owner identities are trusted runtime metadata, so these Rego tests
// load the already-normalized data that follows policy conversion.
let mut engine = regorus::Engine::new();
engine
.add_policy("policy.rego".into(), TEST_POLICY.into())
.expect("load owner admission policy");
engine
.add_data_json(&serde_json::json!({"network_policies": policies}).to_string())
.expect("load normalized owner metadata");
OpaEngine::with_engine(engine, true)
}
fn token_grant_owner_endpoint(owner: &str, path: &str, method: &str) -> serde_json::Value {
serde_json::json!({
"host": "api.example.test", "ports": [8080], "path": path,
"protocol": "rest", "token_grant_owner": owner,
"rules": [{"allow": {"method": method, "path": path}}],
})
}
fn eval_token_grant_owners(engine: &OpaEngine, input: &serde_json::Value) -> Vec<String> {
let mut engine = engine.engine.lock().expect("owner query engine lock");
set_regorus_input(&mut engine, input.clone()).expect("owner query input");
let owners = engine
.eval_rule("data.openshell.sandbox.allowed_token_grant_owners".into())
.expect("evaluate admitted grant owners");
let regorus::Value::Array(owners) = owners else {
panic!("owner query must always return an array, including denial");
};
owners
.iter()
.map(|owner| match owner {
regorus::Value::String(owner) => owner.to_string(),
_ => panic!("owner query must only return string identities"),
})
.collect()
}
#[test]
fn l7_token_grant_owners_require_exact_endpoint_admission() {
let engine = token_grant_owner_engine(serde_json::json!({
"allowed": {
"endpoints": [
token_grant_owner_endpoint("owner-broad", "/a/**", "GET"),
token_grant_owner_endpoint("owner-narrow", "/a/private/**", "POST"),
],
"binaries": [{"path": "/usr/bin/curl"}],
},
"other_binary": {
"endpoints": [token_grant_owner_endpoint("owner-other", "/a/**", "GET")],
"binaries": [{"path": "/usr/bin/other-client"}],
},
}));
let input = l7_input("api.example.test", 8080, "GET", "/a/private/item");
assert!(
eval_l7(&engine, &input),
"broader endpoint permits forwarding"
);
assert_eq!(eval_token_grant_owners(&engine, &input), ["owner-broad"]);
let input = l7_input("api.example.test", 8080, "POST", "/a/private/item");
assert!(eval_l7(&engine, &input));
assert_eq!(eval_token_grant_owners(&engine, &input), ["owner-narrow"]);
}
#[test]
fn l7_token_grant_owners_preserve_union_allow_and_global_deny() {
let mut broad = token_grant_owner_endpoint("owner-z", "/a/**", "GET");
broad["deny_rules"] = serde_json::json!([{"method": "GET", "path": "/a/private/blocked"}]);
let engine = token_grant_owner_engine(serde_json::json!({
"broad": {
"endpoints": [broad],
"binaries": [{"path": "/usr/bin/curl"}],
},
"narrow": {
"endpoints": [
token_grant_owner_endpoint("owner-a", "/a/private/**", "GET"),
token_grant_owner_endpoint("owner-z", "/a/private/**", "GET"),
],
"binaries": [{"path": "/usr/bin/curl"}],
},
}));
let allowed = l7_input("api.example.test", 8080, "GET", "/a/private/item");
assert!(eval_l7(&engine, &allowed));
assert_eq!(
eval_token_grant_owners(&engine, &allowed),
["owner-a", "owner-z"]
);
let denied = l7_input("api.example.test", 8080, "GET", "/a/private/blocked");
assert!(!eval_l7(&engine, &denied));
assert!(eval_token_grant_owners(&engine, &denied).is_empty());
}
#[test]
fn l7_token_grant_owners_preserve_binary_and_query_restrictions() {
let mut endpoint = token_grant_owner_endpoint("owner", "/a/**", "GET");
endpoint["rules"][0]["allow"]["query"] = serde_json::json!({"team": {"glob": "prod-*"}});
let engine = token_grant_owner_engine(serde_json::json!({
"owner": {
"endpoints": [endpoint],
"binaries": [{"path": "/opt/tools/*"}],
},
}));
let mut input = l7_input_with_query(
"api.example.test",
8080,
"GET",
"/a/item",
serde_json::json!({"team": ["prod-east"]}),
);
input["exec"]["cmdline_paths"] = serde_json::json!(["/opt/tools/client"]);
assert!(
eval_token_grant_owners(&engine, &input).is_empty(),
"argv cannot grant access"
);
input["exec"]["ancestors"] = serde_json::json!(["/opt/tools/client"]);
assert_eq!(eval_token_grant_owners(&engine, &input), ["owner"]);
input["request"]["query_params"] = serde_json::json!({"team": ["prod-east", "dev"]});
assert!(
eval_token_grant_owners(&engine, &input).is_empty(),
"every query value must match"
);
}
#[test]
fn l7_token_grant_owners_exclude_audit_denial_and_missing_metadata() {
let mut audit = token_grant_owner_endpoint("owner-audit", "/a/**", "GET");
audit["enforcement"] = "audit".into();
let mut missing = token_grant_owner_endpoint("", "/b/**", "GET");
missing
.as_object_mut()
.expect("endpoint object")
.remove("token_grant_owner");
let mut malformed = token_grant_owner_endpoint("", "/c/**", "GET");
malformed["token_grant_owner"] = 42.into();
let engine = token_grant_owner_engine(serde_json::json!({
"owner": {
"endpoints": [audit, missing, malformed, token_grant_owner_endpoint("", "/d/**", "GET")],
"binaries": [{"path": "/usr/bin/curl"}],
},
}));
let audit_denied = l7_input("api.example.test", 8080, "POST", "/a/item");
assert!(!eval_l7(&engine, &audit_denied));
assert!(eval_token_grant_owners(&engine, &audit_denied).is_empty());
for path in ["/b/item", "/c/item", "/d/item"] {
let input = l7_input("api.example.test", 8080, "GET", path);
assert!(eval_l7(&engine, &input));
assert!(eval_token_grant_owners(&engine, &input).is_empty());
}
}
#[test]
fn l7_token_grant_owners_preserve_proto_identity() {
let mut proto = test_proto();
proto.network_policies = std::collections::HashMap::from([(
"owner".to_owned(),
NetworkPolicyRule {
name: "owner".to_owned(),
endpoints: vec![NetworkEndpoint {
host: "api.example.test".to_owned(),
port: 8080,
path: "/a/**".to_owned(),
protocol: "rest".to_owned(),
token_grant_owner: "gateway-stamped-owner".to_owned(),
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "GET".to_owned(),
path: "/a/**".to_owned(),
..Default::default()
}),
}],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".to_owned(),
}],
},
)]);
let engine = OpaEngine::from_proto(&proto).expect("load stamped proto endpoint");
let input = l7_input("api.example.test", 8080, "GET", "/a/item");
assert!(eval_l7(&engine, &input));
assert_eq!(
eval_token_grant_owners(&engine, &input),
["gateway-stamped-owner"]
);
}
#[test]
fn l7_get_allowed_by_rules() {
let engine = l7_engine();
@@ -1191,6 +1191,7 @@ fn network_endpoint_from_json(
.collect();
Ok(NetworkEndpoint {
token_grant_owner: String::new(),
host: endpoint.host,
port,
protocol: endpoint.protocol,
@@ -6,6 +6,8 @@
pub(crate) mod destination;
mod egress;
mod relay;
#[cfg(test)]
pub(crate) use relay::relay_inspected_http_stream_for_test;
use crate::identity::{BinaryIdentityCache, SuppliedIdentityError};
use crate::l7::tls::ProxyTlsState;
@@ -220,18 +222,7 @@ fn revision_scoped_dynamic_credentials(
snapshot
.dynamic_credentials
.iter()
.map(|(key, credential)| {
let scoped_key = key.rsplit_once('\t').map_or_else(
|| format!("rev:{}\t{key}", snapshot.revision),
|(endpoint_selector, provider_credential)| {
format!(
"{endpoint_selector}\trev:{}\t{provider_credential}",
snapshot.revision
)
},
);
(scoped_key, credential.clone())
})
.map(|(key, credential)| (snapshot.scoped_key(key), credential.clone()))
.collect()
}
@@ -5268,25 +5259,32 @@ async fn inject_token_grant_for_forward_request(
method: &str,
upstream_target: &str,
forward_request_bytes: Vec<u8>,
l7_ctx: &crate::l7::relay::L7EvalContext,
l7_ctx: &mut crate::l7::relay::L7EvalContext,
inspection: Option<ForwardL7Reevaluation<'_>>,
) -> Result<Vec<u8>> {
let header_end = forward_request_bytes
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map_or(forward_request_bytes.len(), |p| p + 4);
let header_str = std::str::from_utf8(&forward_request_bytes[..header_end])
.into_diagnostic()
.map_err(|_| miette::miette!("Forward HTTP headers contain invalid UTF-8"))?;
let body_length = crate::l7::rest::parse_body_length(header_str)?;
let forward_request_for_token_grant = crate::l7::provider::L7Request {
action: method.to_string(),
target: upstream_target.to_string(),
query_params: std::collections::HashMap::new(),
raw_header: forward_request_bytes,
body_length,
};
let (path, _) = crate::l7::rest::parse_target_query(upstream_target)?;
let request = crate::l7::rest::request_from_buffered_http(
method,
path,
upstream_target,
forward_request_bytes,
)?;
if let Some(inspection) = inspection {
let (request, prepared_ctx) = crate::l7::relay::prepare_inspected_request(
request,
l7_ctx,
inspection.engine,
inspection.config,
inspection.request_info,
)
.await?;
*l7_ctx = prepared_ctx;
return Ok(request.raw_header);
}
crate::l7::token_grant_injection::inject_if_needed(forward_request_for_token_grant, l7_ctx)
// L4-only forwarding keeps selector-based credentials; it has no inspected
// endpoint owner decision. Inspected requests always use preparation above.
crate::l7::token_grant_injection::inject_if_needed(request, l7_ctx)
.await
.map(|req| req.raw_header)
}
@@ -6366,7 +6364,15 @@ async fn handle_forward_proxy(
method,
&upstream_target,
forward_request_bytes,
&l7_ctx,
&mut l7_ctx,
forward_l7_reeval
.as_ref()
.zip(forward_tunnel_engine.as_ref())
.map(|((config, request_info), engine)| ForwardL7Reevaluation {
config,
engine,
request_info,
}),
)
.await
{
@@ -6400,21 +6406,30 @@ async fn handle_forward_proxy(
// asynchronous admission step. Holding an endpoint-scoped resolver across
// middleware or token-grant awaits would let a revoked generation reach
// the upstream.
let endpoint_credentials = endpoint_credentials_for_request(
l7_ctx.provider_credentials.as_ref(),
l7_ctx.secret_resolver.clone(),
&host_lc,
port,
&path,
);
let endpoint_credentials = if forward_l7_reeval.is_some() {
ForwardEndpointCredentials {
resolver: l7_ctx.secret_resolver.clone(),
body_classifier: l7_ctx.body_classifier.clone(),
revision: l7_ctx.provider_credential_revision,
}
} else {
endpoint_credentials_for_request(
l7_ctx.provider_credentials.as_ref(),
l7_ctx.secret_resolver.clone(),
&host_lc,
port,
&path,
)
};
let secret_resolver = endpoint_credentials.resolver;
let credential_generation = match (
l7_ctx.provider_credentials.as_ref(),
endpoint_credentials.revision,
) {
(Some(state), Some(revision)) => Some(crate::l7::rest::CredentialGenerationGuard::new(
state, revision,
)),
(Some(state), Some(revision)) => Some(
crate::l7::rest::CredentialGenerationGuard::new(state, revision)
.with_installation_id(l7_ctx.provider_credential_installation_id.as_deref()),
),
_ => None,
};
if let Some(guard) = credential_generation {
@@ -10256,7 +10271,7 @@ network_policies:
},
);
let snapshot = ProviderCredentialSnapshot {
installation_id: String::new(),
installation_id: "fixture-installation".to_string(),
revision: 42,
child_env: std::collections::HashMap::new(),
dynamic_credentials,
@@ -10265,7 +10280,7 @@ network_policies:
let scoped = revision_scoped_dynamic_credentials(&snapshot);
assert!(
scoped.contains_key("api.example.test\t443\t/v1/**\trev:42\tprovider:access_token")
scoped.contains_key("api.example.test\t443\t/v1/**\trev:42\tinstallation:fixture-installation\tprovider:access_token")
);
}
@@ -13191,12 +13206,13 @@ network_policies:
#[tokio::test]
async fn forward_proxy_injects_token_grant_before_rewriting_request() {
let (ctx, fixture) = forward_token_grant_context(Ok("grant-token"));
let (mut ctx, fixture) = forward_token_grant_context(Ok("grant-token"));
let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec();
let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx)
.await
.expect("forward token grant should inject");
let with_token =
inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &mut ctx, None)
.await
.expect("forward token grant should inject");
let rewritten = rewrite_forward_request(
&with_token,
with_token.len(),
@@ -13216,12 +13232,13 @@ network_policies:
#[tokio::test]
async fn forward_proxy_injects_token_exchange_before_rewriting_request() {
let (ctx, fixture) = forward_token_exchange_context(Ok("grant-token"));
let (mut ctx, fixture) = forward_token_exchange_context(Ok("grant-token"));
let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec();
let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx)
.await
.expect("forward token exchange should inject");
let with_token =
inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &mut ctx, None)
.await
.expect("forward token exchange should inject");
let rewritten = rewrite_forward_request(
&with_token,
with_token.len(),
@@ -13243,12 +13260,13 @@ network_policies:
#[tokio::test]
async fn forward_proxy_token_grant_failure_returns_error_before_rewrite() {
let (ctx, fixture) = forward_token_grant_context(Err("oauth unavailable"));
let (mut ctx, fixture) = forward_token_grant_context(Err("oauth unavailable"));
let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec();
let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx)
.await
.expect_err("forward token grant failure should stop request rewriting");
let err =
inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &mut ctx, None)
.await
.expect_err("forward token grant failure should stop request rewriting");
assert_eq!(err.to_string(), "Token grant failed");
fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token");
@@ -13256,12 +13274,13 @@ network_policies:
#[tokio::test]
async fn forward_proxy_token_exchange_failure_returns_error_before_rewrite() {
let (ctx, fixture) = forward_token_exchange_context(Err("oauth unavailable"));
let (mut ctx, fixture) = forward_token_exchange_context(Err("oauth unavailable"));
let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec();
let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx)
.await
.expect_err("forward token exchange failure should stop request rewriting");
let err =
inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &mut ctx, None)
.await
.expect_err("forward token exchange failure should stop request rewriting");
assert_eq!(err.to_string(), "Token grant failed");
fixture.assert_one_token_exchange_request(
@@ -15317,4 +15336,6 @@ network_policies:
#[path = "compatibility.rs"]
mod compatibility;
#[path = "token_grants.rs"]
mod token_grants;
}
@@ -91,6 +91,7 @@ pub(super) fn http_context(
secret_resolver,
provider_credentials,
provider_credential_revision: None,
provider_credential_installation_id: None,
body_classifier: None,
activity_tx: signals.activity,
dynamic_credentials: dynamic_credentials.clone(),
@@ -294,6 +295,35 @@ where
}
}
// Exercise the production single/multiple-route dispatch from L7 fixtures.
#[cfg(test)]
pub async fn relay_inspected_http_stream_for_test<C, U>(
client: &mut C,
upstream: &mut U,
configs: Vec<crate::l7::L7EndpointConfig>,
evaluator: TunnelPolicyEngine,
middleware_engine: &OpaEngine,
request: &L7EvalContext,
) -> Result<()>
where
C: AsyncRead + AsyncWrite + Unpin + Send,
U: AsyncRead + AsyncWrite + Unpin + Send,
{
relay_http_stream(
client,
upstream,
RelayContext {
request,
policy: PreparedHttpPolicy::Inspect {
configs,
evaluator: Box::new(evaluator),
},
middleware_engine,
},
)
.await
}
/// Relay a policy-authorized raw TCP stream.
pub(super) async fn relay_tcp<C, U>(
client: &mut C,
@@ -364,6 +394,7 @@ mod tests {
secret_resolver: None,
provider_credentials: None,
provider_credential_revision: None,
provider_credential_installation_id: None,
body_classifier: None,
activity_tx: None,
dynamic_credentials: None,
@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use super::*;
const POLICY: &str = r#"
network_policies:
rest_api:
name: rest_api
endpoints:
- host: api.example.test
port: 8080
path: /v1/**
protocol: rest
token_grant_owner: other-owner
rules:
- allow: { method: GET, path: /v1/** }
- host: api.example.test
port: 8080
path: /v1/projects
protocol: rest
token_grant_owner: test-owner
rules:
- allow: { method: POST, path: /v1/projects }
binaries:
- { path: /usr/bin/node }
"#;
fn forward_inspection_fixture() -> (
crate::l7::L7EndpointConfig,
crate::opa::TunnelPolicyEngine,
crate::l7::relay::L7EvalContext,
crate::l7::token_grant_injection::test_support::TokenGrantTestFixture,
) {
let (config, engine, mut ctx) = forward_websocket_policy_parts(
POLICY,
"api.example.test",
8080,
"/v1/projects",
"rest_api",
);
let fixture = forward_token_grant_fixture(
"api.example.test\t8080\t/v1/**\tprovider:access_token",
Ok("grant-token"),
false,
);
ctx.dynamic_credentials = Some(fixture.dynamic_credentials());
ctx.provider_credentials = Some(fixture.provider_credentials());
ctx.token_grant_resolver = Some(fixture.resolver());
(config, engine, ctx, fixture)
}
#[tokio::test]
async fn forward_inspection_selects_only_the_owner_admitting_the_request() {
for method in ["GET", "POST"] {
let (config, engine, mut ctx, fixture) = forward_inspection_fixture();
let info = crate::l7::L7RequestInfo {
action: method.into(),
target: "/v1/projects".into(),
query_params: TestHashMap::new(),
graphql: None,
jsonrpc: None,
};
assert!(
crate::l7::relay::evaluate_l7_request(&engine, &ctx, &info)
.unwrap()
.0
);
let raw = format!(
"{method} http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nContent-Length: 0\r\n\r\n"
).into_bytes();
let prepared = inject_token_grant_for_forward_request(
method,
"/v1/projects",
raw,
&mut ctx,
Some(ForwardL7Reevaluation {
config: &config,
engine: &engine,
request_info: &info,
}),
)
.await
.expect("prepare admitted forward request");
let rewritten = rewrite_forward_request(
&prepared,
prepared.len(),
"/v1/projects",
"api.example.test:8080",
ctx.secret_resolver.as_deref(),
)
.expect("rewrite admitted forward request");
let headers = String::from_utf8(rewritten).unwrap();
assert!(headers.starts_with(&format!("{method} /v1/projects HTTP/1.1\r\n")));
assert_eq!(authorization_header_count(&headers), 1);
if method == "POST" {
fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token");
assert!(headers.contains("Authorization: Bearer grant-token\r\n"));
assert!(!headers.contains("stale-token"));
} else {
fixture.assert_no_requests();
assert!(headers.contains("Authorization: Bearer stale-token\r\n"));
assert!(!headers.contains("grant-token"));
}
}
}
#[tokio::test]
async fn forward_inspection_retains_installation_through_guarded_write() {
let (config, engine, mut ctx, fixture) = forward_inspection_fixture();
let state = ctx.provider_credentials.as_ref().unwrap().clone();
let info = crate::l7::L7RequestInfo {
action: "POST".into(),
target: "/v1/projects".into(),
query_params: TestHashMap::new(),
graphql: None,
jsonrpc: None,
};
let raw = b"POST http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nContent-Length: 0\r\n\r\n".to_vec();
let prepared = inject_token_grant_for_forward_request(
"POST",
"/v1/projects",
raw,
&mut ctx,
Some(ForwardL7Reevaluation {
config: &config,
engine: &engine,
request_info: &info,
}),
)
.await
.expect("prepare authenticated request");
fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token");
let rewritten = rewrite_forward_request(
&prepared,
prepared.len(),
"/v1/projects",
"api.example.test:8080",
ctx.secret_resolver.as_deref(),
)
.unwrap();
let snapshot = state.snapshot();
state.install_environment(
snapshot.revision,
TestHashMap::new(),
TestHashMap::new(),
fixture.dynamic_credentials().read().unwrap().clone(),
);
let guard = crate::l7::rest::CredentialGenerationGuard::new(
&state,
ctx.provider_credential_revision.expect("prepared revision"),
)
.with_installation_id(ctx.provider_credential_installation_id.as_deref());
let (mut client, _app) = tokio::io::duplex(1024);
let (mut upstream, mut target) = tokio::io::duplex(1024);
let relay = relay_rewritten_forward_request(
"POST",
"/v1/projects",
rewritten,
&mut client,
&mut upstream,
ForwardRelayOptions {
generation_guard: engine.generation_guard(),
credential_generation: Some(guard),
websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve,
secret_resolver: ctx.secret_resolver.as_deref(),
body_classifier: ctx.body_classifier.as_deref(),
request_body_credential_rewrite: false,
deny_uninspected_credentials: false,
credential_signing: crate::l7::CredentialSigning::None,
signing_service: "",
signing_region: "",
host: &ctx.host,
port: ctx.port,
response_middleware: None,
endpoint_observer: None,
},
);
let result = tokio::time::timeout(std::time::Duration::from_secs(5), relay)
.await
.expect("credential rejection must finish before any response wait");
assert!(
result
.unwrap_err()
.downcast_ref::<crate::l7::rest::CredentialUnavailableError>()
.is_some()
);
drop(upstream);
let mut forwarded = Vec::new();
target.read_to_end(&mut forwarded).await.unwrap();
assert!(
forwarded.is_empty(),
"replaced installation cannot send any bytes"
);
}
@@ -421,12 +421,11 @@ fn supervisor_sandbox_id_from_env() -> Result<String> {
}
fn parse_provider_credential_key(key: &str) -> Result<(&str, &str)> {
let provider_and_credential = key
.rsplit_once('\t')
.map_or(key, |(_, provider_and_credential)| provider_and_credential);
provider_and_credential.split_once(':').ok_or_else(|| {
miette::miette!("dynamic token grant key is missing provider credential identity")
})
openshell_core::dynamic_credential_key::credential_identity(key)
.and_then(|identity| identity.split_once(':'))
.ok_or_else(|| {
miette::miette!("dynamic token grant key is missing provider credential identity")
})
}
struct TokenCacheKeyInput<'a> {
@@ -463,6 +462,151 @@ fn current_time_ms() -> i64 {
i64::try_from(millis).unwrap_or(i64::MAX)
}
#[cfg(test)]
pub mod test_support {
//! Relay fixtures using the production token cache.
//!
//! Each resolver owns its cache. Only acquisition is synthetic: no SPIFFE
//! socket or OAuth service is contacted, and no process-wide cache is used.
use super::*;
use crate::l7::token_grant_injection::{TokenGrantRequest, TokenGrantResolver};
use std::collections::HashSet;
use std::pin::Pin;
use std::sync::Mutex;
/// One actual acquisition, excluding requests served from the cache.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheAcquisition {
pub(crate) provider_key: String,
pub(crate) audience: String,
}
struct RecordedAcquisition {
request: CacheAcquisition,
cache_key: String,
}
/// A private production cache with deterministic synthetic token acquisition.
///
/// Misses return `inert-{audience}-{acquisition ordinal}`. The ordinal makes
/// reacquisition observable even when the provider and audience stay the same.
pub struct CachedTokenGrantResolver {
cache: TokenCache,
acquisitions: Mutex<Vec<RecordedAcquisition>>,
}
impl CachedTokenGrantResolver {
pub(crate) fn new() -> Self {
Self {
cache: TokenCache::new(),
acquisitions: Mutex::new(Vec::new()),
}
}
pub(crate) fn acquisitions(&self) -> Vec<CacheAcquisition> {
self.acquisitions
.lock()
.expect("cached resolver acquisitions lock")
.iter()
.map(|acquisition| acquisition.request.clone())
.collect()
}
/// Expire acquired entries for `provider:credential` and audience.
///
/// This changes only the timestamp, preserving the cached value so the
/// next request must pass the production expiry check to obtain a new
/// token. The returned count proves an entry was present; no sleep or
/// modification of the shared production cache is needed.
pub(crate) fn expire(&self, provider_identity: &str, audience: &str) -> usize {
let acquisitions = self
.acquisitions
.lock()
.expect("cached resolver acquisitions lock");
let keys = acquisitions
.iter()
.filter(|acquisition| {
openshell_core::dynamic_credential_key::credential_identity(
&acquisition.request.provider_key,
) == Some(provider_identity)
&& acquisition.request.audience == audience
})
.map(|acquisition| acquisition.cache_key.as_str())
.collect::<HashSet<_>>();
let mut cached = self.cache.tokens.write().expect("test token cache lock");
let mut expired = 0;
for key in keys {
if let Some(token) = cached.get_mut(key) {
token.expires_at_ms = 0;
expired += 1;
}
}
expired
}
}
impl TokenGrantResolver for CachedTokenGrantResolver {
fn obtain<'a>(
&'a self,
request: TokenGrantRequest<'a>,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
let grant_type = ProviderCredentialTokenGrantType::try_from(request.grant_type)
.map_err(|_| miette::miette!("unknown test token grant type"))?;
obtain_provider_token_with_grant(
ObtainProviderTokenInput {
cache: &self.cache,
provider_name: request.provider_key,
token_endpoint: request.token_endpoint,
jwt_svid_audience: request.jwt_svid_audience,
client_assertion_type: request.client_assertion_type,
audience: request.audience,
scopes: request.scopes,
cache_ttl_override: request.cache_ttl,
grant_type,
requested_token_type: request.requested_token_type,
},
|jwt_audience| async move {
let cache_key = token_cache_key(TokenCacheKeyInput {
provider_name: request.provider_key,
token_endpoint: request.token_endpoint,
jwt_svid_audience: &jwt_audience,
client_assertion_type: effective_client_assertion_type(
request.client_assertion_type,
),
audience: request.audience,
scopes: request.scopes,
grant_type,
requested_token_type: effective_token_type(
request.requested_token_type,
),
});
let mut acquisitions = self
.acquisitions
.lock()
.expect("cached resolver acquisitions lock");
let ordinal = acquisitions.len() + 1;
acquisitions.push(RecordedAcquisition {
request: CacheAcquisition {
provider_key: request.provider_key.to_owned(),
audience: request.audience.to_owned(),
},
cache_key,
});
Ok(OAuthTokenResponse {
access_token: format!("inert-{}-{ordinal}", request.audience),
token_type: "Bearer".to_owned(),
expires_in: MAX_TOKEN_EXPIRES_IN_SECONDS,
})
},
)
.await
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -845,7 +989,7 @@ mod tests {
}
#[test]
fn provider_credential_key_parser_ignores_revision_segment() {
fn provider_credential_key_parser_preserves_identity_and_rejects_extra_fields() {
assert_eq!(
parse_provider_credential_key(
"api.example.test\t443\t/v1/**\trev:42\tprovider:access_token"
@@ -853,6 +997,12 @@ mod tests {
.expect("parse provider credential key"),
("provider", "access_token")
);
assert!(
parse_provider_credential_key(
"api.example.test\t443\t/v1/**\towner\tprovider:a\tother:access_token"
)
.is_err()
);
}
#[test]
@@ -354,7 +354,7 @@ struct SessionConfig {
}
async fn run_session_loop(config: SessionConfig) {
let mut backoff = INITIAL_BACKOFF;
let mut backoff = ReconnectBackoff::new();
let mut attempt: u64 = 0;
// The gateway may hand this sandbox to the replica that should own it. We
@@ -363,7 +363,6 @@ async fn run_session_loop(config: SessionConfig) {
// back to the configured address, which load-balances across replicas.
let mut target = config.endpoint.clone();
let mut redirected = false;
let mut backoff_skipped = false;
loop {
attempt += 1;
@@ -384,9 +383,7 @@ async fn run_session_loop(config: SessionConfig) {
peer_endpoint,
owner_replica_id,
}) => {
if config.ready_tx.send_replace(false) {
backoff_skipped = false;
}
backoff.on_redirect(config.ready_tx.send_replace(false));
info!(
sandbox_id = %config.sandbox_id,
owner_replica_id = %owner_replica_id,
@@ -409,22 +406,60 @@ async fn run_session_loop(config: SessionConfig) {
// Fall back to the configured gateway address so a redirect
// to a replica that is going away cannot strand the sandbox.
let failed_redirect_target = target != config.endpoint && !accepted;
if accepted {
backoff_skipped = false;
}
target.clone_from(&config.endpoint);
redirected = redirect_survives_failure(redirected, accepted);
if skip_backoff(failed_redirect_target, backoff_skipped) {
backoff_skipped = true;
} else {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
if let Some(delay) = backoff.on_failure(accepted, failed_redirect_target) {
tokio::time::sleep(delay).await;
}
}
}
}
}
/// Reconnect delays for the supervisor session loop.
///
/// Delays double up to [`MAX_BACKOFF`] while reconnects keep failing. A session
/// the gateway accepted proves the path works again, so its loss starts the
/// delays over. Without that reset, every gateway restart over the sandbox's
/// lifetime would compound until each reconnect waited the full maximum.
#[derive(Debug)]
struct ReconnectBackoff {
delay: Duration,
skipped_since_accept: bool,
}
impl ReconnectBackoff {
fn new() -> Self {
Self {
delay: INITIAL_BACKOFF,
skipped_since_accept: false,
}
}
/// Record a gateway redirect. Following it is an expected handoff, so the
/// supervisor reconnects without waiting.
fn on_redirect(&mut self, accepted: bool) {
if accepted {
*self = Self::new();
}
}
/// Record a failed or dropped session and return how long to wait before
/// the next attempt, or `None` to reconnect at once.
fn on_failure(&mut self, accepted: bool, failed_redirect_target: bool) -> Option<Duration> {
if accepted {
*self = Self::new();
}
if skip_backoff(failed_redirect_target, self.skipped_since_accept) {
self.skipped_since_accept = true;
return None;
}
let delay = self.delay;
self.delay = (self.delay * 2).min(MAX_BACKOFF);
Some(delay)
}
}
/// Keep asking not to be redirected when a redirected attempt never got a
/// session, so a stale ring pointing at a dead replica cannot bounce us back
/// to it. A session that was accepted and later dropped starts placement over.
@@ -1315,6 +1350,43 @@ mod ocsf_event_tests {
assert!(!redirect_survives_failure(false, true));
}
#[test]
fn reconnect_backoff_doubles_until_a_session_is_accepted() {
let mut backoff = ReconnectBackoff::new();
let delays: Vec<_> = (0..7)
.map(|_| backoff.on_failure(false, false).unwrap())
.collect();
assert_eq!(
delays,
[1, 2, 4, 8, 16, 30, 30].map(Duration::from_secs).to_vec()
);
// Losing an accepted session starts over instead of waiting the maximum.
assert_eq!(backoff.on_failure(true, false), Some(INITIAL_BACKOFF));
assert_eq!(backoff.on_failure(false, false), Some(INITIAL_BACKOFF * 2));
}
#[test]
fn reconnect_backoff_resets_when_an_accepted_session_redirects() {
let mut backoff = ReconnectBackoff::new();
for _ in 0..5 {
backoff.on_failure(false, false);
}
backoff.on_redirect(true);
assert_eq!(backoff.on_failure(false, false), Some(INITIAL_BACKOFF));
}
#[test]
fn reconnect_backoff_skips_one_failed_redirect_target_per_accepted_session() {
let mut backoff = ReconnectBackoff::new();
assert_eq!(backoff.on_failure(false, true), None);
assert_eq!(backoff.on_failure(false, true), Some(INITIAL_BACKOFF));
backoff.on_redirect(true);
assert_eq!(backoff.on_failure(false, true), None);
}
#[test]
fn backoff_is_skipped_once_per_accepted_session() {
assert!(skip_backoff(true, false));
+6
View File
@@ -15,3 +15,9 @@ The supervisor admits policy and prepares credentials before constructing and at
The client receives the supervisor's live provider state, bearer-token slot, and CA-path slot. Provider refresh, token rotation, and later CA publication must remain visible through those shared handles. Startup does not create independent copies of their current values.
The setup interface stays private to the supervisor. It adds no runtime backend registration, endpoint configuration, or public factory API. The public `run_sandbox` signature and standard backend selection remain unchanged.
## Console logging
Diagnostics and OCSF shorthand share a bounded, nonblocking stderr writer. With `ocsf_json_enabled=true`, the same writer also receives timestamped `OCSF-JSON` records. Each formatter submits a complete line in one write so concurrent producers cannot interleave records in the queue. The 1,024-line queue drops new lines when full rather than waiting for stderr.
Console JSON is installed independently of optional file appenders and uses an INFO filter independent of the diagnostic filter. Its runtime enabled flag and target schema version are shared with the existing JSONL file layer. The supervisor retains the writer guards until shutdown. The gateway log push layer continues to emit shorthand only.
+272
View File
@@ -0,0 +1,272 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Supervisor console and optional file destinations.
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use openshell_ocsf::{OcsfJsonlLayer, OcsfShorthandLayer};
use tracing::Subscriber;
use tracing_appender::non_blocking::{NonBlockingBuilder, WorkerGuard};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{EnvFilter, Layer, filter::LevelFilter, registry::LookupSpan};
const CONSOLE_QUEUE_LINES: usize = 1024;
/// Install console output even when the optional file sinks cannot open.
/// All console formatters share one bounded queue and submit whole records.
pub fn layers<S, W>(
console: W,
directory: &Path,
console_filter: EnvFilter,
enabled: Arc<AtomicBool>,
schema_version: Arc<Mutex<String>>,
) -> (impl Layer<S>, Vec<WorkerGuard>, bool)
where
S: Subscriber + for<'a> LookupSpan<'a> + 'static,
W: Write + Send + 'static,
{
let (console, console_guard) = NonBlockingBuilder::default()
.buffered_lines_limit(CONSOLE_QUEUE_LINES)
.lossy(true)
.thread_name("openshell-console")
.finish(console);
let mut guards = vec![console_guard];
let shorthand_file = file_writer(directory, "openshell", &mut guards);
let file_available = shorthand_file.is_some();
// Preserve the existing file-sink behavior while making stderr independent.
let json_file = if file_available {
file_writer(directory, "openshell-ocsf", &mut guards)
} else {
None
};
let shorthand_file = shorthand_file
.map(|writer| OcsfShorthandLayer::new(writer).with_filter(EnvFilter::new("info")));
let json_file = json_file.map(|writer| {
OcsfJsonlLayer::new(writer)
.with_enabled_flag(enabled.clone())
.with_target_version(schema_version.clone())
.with_filter(LevelFilter::INFO)
});
// Keep per-destination filters independent: a diagnostic filter must not
// veto an explicitly enabled JSON record in another destination.
let layers: Vec<Box<dyn Layer<S> + Send + Sync>> = vec![
OcsfShorthandLayer::new(console.clone())
.with_filter(console_filter)
.boxed(),
OcsfJsonlLayer::new(console)
.with_console_format()
.with_enabled_flag(enabled)
.with_target_version(schema_version)
.with_filter(LevelFilter::INFO)
.boxed(),
shorthand_file.boxed(),
json_file.boxed(),
];
(layers, guards, file_available)
}
fn file_writer(
directory: &Path,
prefix: &str,
guards: &mut Vec<WorkerGuard>,
) -> Option<tracing_appender::non_blocking::NonBlocking> {
let roller = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(prefix)
.filename_suffix("log")
.max_log_files(3)
.build(directory)
.ok()?;
let (writer, guard) = tracing_appender::non_blocking(roller);
guards.push(guard);
Some(writer)
}
#[cfg(test)]
mod tests {
use super::*;
use openshell_ocsf::{EventContext, NetworkActivityBuilder, ocsf_emit};
use tracing_subscriber::layer::SubscriberExt;
#[derive(Clone)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl Write for Capture {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn context() -> EventContext {
EventContext {
sandbox_id: "sb-console".to_string(),
sandbox_name: "console-test".to_string(),
container_image: "test-image".to_string(),
hostname: "test-host".to_string(),
product_version: "test".to_string(),
proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
proxy_port: 3128,
origin: openshell_ocsf::EventOrigin::Supervisor,
}
}
#[test]
fn console_json_survives_file_failure_and_disabled_diagnostics() {
let directory = tempfile::tempdir().unwrap();
let invalid_directory = directory.path().join("file");
std::fs::write(&invalid_directory, "not a directory").unwrap();
let output = Arc::new(Mutex::new(Vec::new()));
let (layers, guards, file_available) = layers(
Capture(output.clone()),
&invalid_directory,
EnvFilter::new("off"),
Arc::new(AtomicBool::new(true)),
Arc::new(Mutex::new(String::new())),
);
assert!(!file_available);
let subscriber = tracing_subscriber::registry().with(layers);
let event = NetworkActivityBuilder::new(&context())
.dst_endpoint(openshell_ocsf::Endpoint::from_domain("example.com", 443))
.message("console without a file sink")
.build();
let expected = event.to_json().unwrap();
tracing::subscriber::with_default(subscriber, || ocsf_emit!(event));
drop(guards);
let output = String::from_utf8(output.lock().unwrap().clone()).unwrap();
let lines: Vec<_> = output.lines().collect();
assert_eq!(lines.len(), 1);
let (_, json) = lines[0].split_once(" OCSF-JSON ").unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(json).unwrap(),
expected
);
}
#[test]
fn console_and_file_json_preserve_the_same_event() {
let directory = tempfile::tempdir().unwrap();
let output = Arc::new(Mutex::new(Vec::new()));
let (layers, guards, file_available) = layers(
Capture(output.clone()),
directory.path(),
EnvFilter::new("info"),
Arc::new(AtomicBool::new(true)),
Arc::new(Mutex::new(String::new())),
);
assert!(file_available);
let subscriber = tracing_subscriber::registry().with(layers);
let event = NetworkActivityBuilder::new(&context())
.dst_endpoint(openshell_ocsf::Endpoint::from_domain("example.com", 443))
.build();
let expected = event.to_json().unwrap();
tracing::subscriber::with_default(subscriber, || ocsf_emit!(event));
drop(guards);
let output = String::from_utf8(output.lock().unwrap().clone()).unwrap();
assert!(output.lines().any(|line| line.contains(" OCSF ")));
let (_, json) = output
.lines()
.find_map(|line| line.split_once(" OCSF-JSON "))
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(json).unwrap(),
expected
);
let path = std::fs::read_dir(directory.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("openshell-ocsf.")
})
.unwrap();
let file = std::fs::read_to_string(path).unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(file.trim()).unwrap(),
expected
);
}
#[test]
fn slow_console_drops_lines_without_blocking_producers() {
use std::sync::{Condvar, mpsc};
use std::time::Duration;
struct SlowConsole {
gate: Arc<(Mutex<bool>, Condvar)>,
started: mpsc::SyncSender<()>,
writes: Arc<std::sync::atomic::AtomicUsize>,
}
impl Write for SlowConsole {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
let _ = self.started.try_send(());
let (lock, ready) = &*self.gate;
let mut released = lock.lock().unwrap();
while !*released {
released = ready.wait(released).unwrap();
}
self.writes
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let directory = tempfile::tempdir().unwrap();
let invalid_directory = directory.path().join("file");
std::fs::write(&invalid_directory, "not a directory").unwrap();
let gate = Arc::new((Mutex::new(false), Condvar::new()));
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (started_tx, started_rx) = mpsc::sync_channel(1);
let (layers, guards, _) = layers(
SlowConsole {
gate: gate.clone(),
started: started_tx,
writes: writes.clone(),
},
&invalid_directory,
EnvFilter::new("info"),
Arc::new(AtomicBool::new(false)),
Arc::new(Mutex::new(String::new())),
);
let dispatch = tracing::Dispatch::new(tracing_subscriber::registry().with(layers));
tracing::dispatcher::with_default(&dispatch, || tracing::info!("occupy writer"));
let started = started_rx.recv_timeout(Duration::from_secs(3));
let (done_tx, done_rx) = mpsc::channel();
let producer = std::thread::spawn(move || {
tracing::dispatcher::with_default(&dispatch, || {
for index in 0..CONSOLE_QUEUE_LINES * 2 {
tracing::info!("queued line {index}");
}
});
done_tx.send(()).unwrap();
});
let completed = done_rx.recv_timeout(Duration::from_secs(3));
// Release before asserting so failure cannot strand the worker guard.
*gate.0.lock().unwrap() = true;
gate.1.notify_all();
producer.join().unwrap();
drop(guards);
assert!(started.is_ok(), "console worker did not start");
assert!(completed.is_ok(), "producer blocked on stderr backpressure");
assert_eq!(
writes.load(std::sync::atomic::Ordering::Relaxed),
CONSOLE_QUEUE_LINES + 1
);
}
}
+21 -67
View File
@@ -10,12 +10,13 @@ use std::sync::atomic::AtomicBool;
use clap::{Parser, ValueEnum};
use miette::{IntoDiagnostic, Result};
use openshell_isolation_interface::contract::BackendDescriptor;
use openshell_ocsf::{OcsfJsonlLayer, OcsfShorthandLayer};
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _};
mod logging;
const DEBUG_RPC_SUBCOMMAND: &str = "debug-rpc";
const HEALTH_SUBCOMMAND: &str = "health";
const SERVICE_NAME: &str = "openshell-supervisor";
@@ -315,17 +316,6 @@ fn main() -> Result<()> {
None
};
let file_logging = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("openshell")
.filename_suffix("log")
.max_log_files(3)
.build("/var/log")
.ok()
.map(|roller| {
let (writer, guard) = tracing_appender::non_blocking(roller);
(writer, guard)
});
let console_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level));
let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -369,62 +359,26 @@ fn main() -> Result<()> {
let ocsf_enabled = Arc::new(AtomicBool::new(false));
let ocsf_schema_version = Arc::new(std::sync::Mutex::new(String::new()));
let (_file_guard, _jsonl_guard) = if let Some((file_writer, file_guard)) = file_logging {
let jsonl_logging = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("openshell-ocsf")
.filename_suffix("log")
.max_log_files(3)
.build("/var/log")
.ok()
.map(|roller| {
let (writer, guard) = tracing_appender::non_blocking(roller);
let layer = OcsfJsonlLayer::new(writer)
.with_enabled_flag(ocsf_enabled.clone())
.with_target_version(ocsf_schema_version.clone());
(layer, guard)
});
let (jsonl_layer, jsonl_guard) =
jsonl_logging.map_or((None, None), |(layer, guard)| (Some(layer), Some(guard)));
tracing_subscriber::registry()
.with(
OcsfShorthandLayer::new(std::io::stderr())
.with_non_ocsf(true)
.with_filter(console_filter),
)
.with(
OcsfShorthandLayer::new(file_writer)
.with_non_ocsf(true)
.with_filter(EnvFilter::new("info")),
)
.with(jsonl_layer.with_filter(LevelFilter::INFO))
.with(push_layer.clone())
.with(
otlp_layer_provider
.as_ref()
.map(|provider| openshell_otel::layer(provider, SERVICE_NAME))
.with_filter(otlp_span_filter(&args.log_level)),
)
.init();
(Some(file_guard), jsonl_guard)
} else {
tracing_subscriber::registry()
.with(
OcsfShorthandLayer::new(std::io::stderr())
.with_non_ocsf(true)
.with_filter(console_filter),
)
.with(push_layer)
.with(
otlp_layer_provider
.as_ref()
.map(|provider| openshell_otel::layer(provider, SERVICE_NAME))
.with_filter(otlp_span_filter(&args.log_level)),
)
.init();
let (logging_layers, _logging_guards, file_available) = logging::layers(
std::io::stderr(),
Path::new("/var/log"),
console_filter,
ocsf_enabled.clone(),
ocsf_schema_version.clone(),
);
tracing_subscriber::registry()
.with(logging_layers)
.with(push_layer)
.with(
otlp_layer_provider
.as_ref()
.map(|provider| openshell_otel::layer(provider, SERVICE_NAME))
.with_filter(otlp_span_filter(&args.log_level)),
)
.init();
if !file_available {
warn!("Could not open /var/log for log rotation; using stderr-only logging");
(None, None)
};
}
if let Some(error) = otlp_setup_error {
warn!(%error, "OTLP exporting could not be started; continuing without it");
} else if let Some(endpoint) = &args.otlp_endpoint {
@@ -215,7 +215,7 @@ The gateway validates OIDC access tokens against the issuer's JWKS endpoint. It
For EC and OKP keys, `crv` fully determines the algorithm. For RSA keys, the algorithm is selected from the JWK's declared `alg` field when it names one of the six RSA algorithms above; if `alg` is absent (as with Microsoft Entra ID, which omits it entirely), the gateway defaults to RS256. If `alg` names a non-RSA algorithm, the key is treated as contradictory and skipped (see below) rather than falling back to RS256.
The gateway rejects tokens when the JWT header `alg` does not match the algorithm pinned from the JWK. Tokens must include `iss`, `aud`, `exp`, and `sub` claims. The `iss` and `aud` values must match the configured issuer and audience.
The gateway accepts the `Bearer` scheme in the `Authorization` header in any letter case. It rejects tokens when the JWT header `alg` does not match the algorithm pinned from the JWK. Tokens must include `iss`, `aud`, `exp`, and `sub` claims. The `iss` and `aud` values must match the configured issuer and audience.
JWKs with `use: enc`, an `alg` that genuinely contradicts the derived algorithm (for example an RSA key declaring `ES256`), or `key_ops` that excludes `verify` are skipped. The gateway refreshes the JWKS cache when it encounters an unknown key ID, throttled to at most once per second to bound how much an unknown-`kid` lookup can amplify requests against the issuer. If a refresh yields zero usable keys, the gateway keeps serving the previous key set rather than dropping all signing keys and failing every request, but only for up to three times the configured TTL. If the JWKS is still empty after that grace period, the cached keys are evicted and all tokens are rejected until the issuer recovers.
+12 -2
View File
@@ -203,7 +203,7 @@ The following provider profile design items are not part of the current behavior
| Roadmap item | Current behavior |
|---|---|
| General profile-driven credential placement | Static `auth_style`, `header_name`, `query_param`, and `path_template` placement metadata is stored and validated, but static credential injection still depends on environment placeholders generated from provider credentials. Dynamic `token_grant` credentials support `bearer` and `header` placement for matching HTTP endpoints. |
| Binary-scoped credential injection | Provider profile binaries affect policy composition but do not yet restrict placeholder resolution by calling binary. Static and dynamic credentials are endpoint-scoped. |
| Binary-scoped credential injection | Static placeholder resolution is endpoint-scoped. Dynamic grants in single-route REST and multi-route inspection also require admission by their owning endpoint's policy, including its binary restrictions. L4-only credential forwarding retains endpoint-selector matching. |
| Credential verification on create | `openshell provider create` does not yet probe provider verification endpoints or expose `--no-verify`. |
| Automatic credential scope extraction | OpenShell does not yet inspect upstream provider responses to discover credential scopes. |
| Policy prover integration | OpenShell does not yet run the policy prover automatically on sandbox startup or block startup based on prover findings. |
@@ -485,6 +485,8 @@ import for compatibility.
`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant 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`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v<digits>_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders.
Credential names must not contain control characters such as tabs or newlines. Dynamic token-grant endpoint and audience-override selectors have the same restriction.
`discovery` controls what `--from-existing` scans. Each entry in
`discovery.credentials` must name a
credential declared under `credentials`. OpenShell scans the referenced
@@ -598,6 +600,14 @@ The supervisor chooses the most-specific matching binding independently for each
All selected grants must succeed and return valid header values before the request is rewritten. A failed grant returns HTTP 502 without forwarding the request. An earlier successful grant may remain in its own cache for a later attempt. Injection replaces every agent-supplied instance of each selected header, including any static credential placeholder in that header, and preserves unrelated headers. Issuer error descriptions are excluded from grant-failure logs and client errors because they may contain credentials.
REST, GraphQL, MCP and JSON-RPC inspection use the same credential preparation step before forwarding each request, including plaintext HTTP forward-proxy requests and CONNECT tunnels. This also applies when endpoints share a host and port or the client reuses a connection. The supervisor selects grants only from endpoints whose policy admits the current request and calling binary. It checks the body again after middleware transformations before selecting credentials. An overlapping endpoint's allow rule cannot authorize another endpoint's grant. Requests forwarded solely because enforcement is `audit` receive no grant from an endpoint that denies them. A gateway-global policy supplies the effective authorization rules when it replaces provider policies; profile host, port and path selectors still limit each credential's destination. L4-only HTTP forwarding retains host, port and path credential selection without L7 owner admission.
`token_grant_owner` is internal metadata identifying the policy endpoint authorized to grant a credential. The gateway derives it from the provider record and profile policy, or from the active global policy. The supervisor requires that authority to admit the request before acquiring the token. User-authored owner values are ignored.
Upgrade the gateway before the supervisor: inspected requests with a matching token grant but missing gateway ownership metadata fail with HTTP 502 instead of being sent without authentication.
Later requests use the latest installed provider credentials. If provider state or policy changes while a grant is being obtained, the supervisor rejects the request before sending it upstream. Reconnect and retry after the new configuration is ready. A token-service failure also closes the request without forwarding it; a later connection can retry the grant.
OpenShell supports two dynamic grant types:
| Grant type | Behavior |
@@ -644,7 +654,7 @@ Token grant fields:
| `subject_token.subject_token_type` | No | RFC 8693 `subject_token_type` for the stored subject token. Defaults to `urn:ietf:params:oauth:token-type:access_token`. |
| `audience_overrides` | No | Endpoint-specific final-exchange `audience` and `scopes` overrides selected by host, port, and path. These overrides do not affect the gateway intermediate exchange. |
Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection.
Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. They apply to HTTP traffic that the proxy can inspect. Token-grant profiles reject endpoints with `protocol: sql` or `tls: skip`, including when `allow_uninspected_credentials` is enabled, because those modes cannot inject the required HTTP credential. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection.
The gateway only brokers an intermediate token for a sandbox principal, and only when the requested provider is attached to that sandbox. It verifies the supervisor JWT-SVID issuer, audience, signature, and SPIFFE trust domain against the gateway's own JWT-SVID, then uses the verified supervisor SVID subject as the intermediate-token audience.
+2
View File
@@ -858,6 +858,8 @@ unrelated entries.
OpenShell preserves symlinks during upload. A symlink arrives in the sandbox as a symlink with the same target path instead of an expanded copy of the target file or directory. Dangling symlinks are also preserved.
For a single file uploaded without Git filtering, an existing destination directory receives `<directory>/<source-basename>`, with or without a trailing `/`. A symlink to a destination directory is preserved, and the file is written inside its target. A missing nested destination without `/` at the end instead names the uploaded file. For example, `openshell sandbox upload my-sandbox ./config.json /sandbox/settings.json --no-git-ignore` writes `settings.json`; uploading to an existing `/sandbox/settings` directory writes `settings/config.json`. Successful unfiltered uploads print the resulting path. Git-filtered uploads still treat the destination as a directory.
Download files from the sandbox to your host:
```shell
+3 -1
View File
@@ -315,4 +315,6 @@ On Docker and Podman, set `process.run_as_user` and `process.run_as_group` in th
MicroVM persists the resolved UID:GID with the writable overlay and retains it across restarts, even if driver defaults change. Explicit policy selectors must match that identity. For example, `run_as_user: "10000"` fails when the overlay owner is `1000:1000`; OpenShell reports both values without changing file ownership. The symbolic selector `sandbox` refers to the guest account for that owner, and each omitted selector accepts its driver default. The `WorkloadIdentity` condition in `openshell sandbox get <name>` shows the resolved pair separately from workload readiness. Canonical and exec processes use this same pair; live policy updates cannot change it.
On Docker, the image's `WORKDIR` becomes the workspace. Images with no `WORKDIR`, `/`, or `/sandbox` use `/sandbox`. Any other `WORKDIR` must exist in the image and be writable by the sandbox user. Podman, Kubernetes, and MicroVM always use `/sandbox`.
On Docker and Podman, the image's `WORKDIR` becomes the workspace. Images with no `WORKDIR`, `/`, or `/sandbox` use `/sandbox`. Any other `WORKDIR` must exist in the image and be writable by the sandbox user. Kubernetes and MicroVM always use `/sandbox`.
Custom Docker and Podman workspaces use the workload container's writable layer, not a managed named volume. Files survive stop/start of the same container, but removing or replacing that container removes its workspace changes. Export files you need to retain before deleting the sandbox.
+9 -2
View File
@@ -178,12 +178,19 @@ When a non-owner gateway pod stops, Kubernetes removes it from ready Service
endpoints and new requests go to another replica. Existing requests through the
stopped pod disconnect and must be retried.
When an owner gateway pod stops gracefully, it redirects its connected
supervisors to the replicas that now own them. After a crash, supervisors
When an owner gateway pod stops gracefully, it re-reads gateway membership and
redirects its connected supervisors to the replicas that now own them,
including replicas that started moments earlier. After a crash, supervisors
reconnect through the gateway Service. Either way, they publish new ownership. Other replicas re-read ownership
while waiting to relay a request. A request fails if no fresh, reachable owner
appears before its normal deadline.
A stopping owner waits up to five seconds for a redirected supervisor to
publish its new session before it marks the sandbox not ready, so a sandbox
stays `Ready` through a graceful handoff. Supervisors reconnect with
exponential backoff that restarts after each accepted session, so repeated
gateway restarts do not lengthen later reconnects.
Established streams do not move between gateway replicas. An interrupted exec,
forwarding, or service stream must reconnect. File sync retries with a new SSH
session instead of resuming the interrupted byte stream. Rolling updates can
+2 -2
View File
@@ -96,7 +96,7 @@ podman cp <supervisor-container>:/var/log/. ./supervisor-logs/
Docker's `docker cp` cannot copy the live contents of the supervisor's tmpfs log mount. Retrieving those files requires operator-managed access to the live mount on the Docker daemon host. The default supervisor image has no shell or `tar`, so shell-based `docker exec` collection is not an alternative.
For live shorthand output, read the supervisor's stderr through the platform log interface:
For live console output, read the supervisor's stderr through the platform log interface:
```shell
kubectl -n <sandbox-namespace> logs <supervisor-pod> -c supervisor --follow
@@ -104,7 +104,7 @@ docker logs --follow <supervisor-container>
podman logs --follow <supervisor-container>
```
This console output follows the supervisor's diagnostic log filter and does not include full OCSF JSON records. A cluster or container log collector can ship it for retention. Collect workload container logs separately for main process output and sandbox-runtime events.
Shorthand and diagnostics follow the supervisor's diagnostic log filter. With `ocsf_json_enabled=true`, stderr also includes compact `OCSF-JSON` records independently of that filter and file storage. See [OCSF JSON collection](/observability/ocsf-json-export#output-location) for the collector format and delivery limits. A cluster or container log collector can ship this output for retention. Collect workload container logs separately for main process output and sandbox-runtime events.
The default supervisor image has no shell or `tar`, so shell-based `kubectl exec` and `kubectl cp` examples do not work with that image. Reading Kubernetes supervisor files requires operator-managed access to its log volume, such as cluster debugging tooling. OpenShell does not configure a file collector for you.
+1 -1
View File
@@ -265,7 +265,7 @@ The supervisor writes logs to `/var/log/` in its own filesystem, separate from t
Each supervisor file appender is configured with a three-file limit. The shorthand appender's filename prefix also matches JSONL files, so its cleanup can delete JSONL history on filesystems that expose file creation timestamps. Do not rely on independent three-file retention for each format.
File output requires a writable `/var/log` and uses non-blocking writers that can drop events under pressure. If file logging cannot start, the supervisor falls back to stderr-only output.
File output requires a writable `/var/log` and uses non-blocking writers that can drop events under pressure. If file logging cannot start, the supervisor falls back to stderr-only output. Enabling `ocsf_json_enabled` adds full `OCSF-JSON` records to stderr even without a file sink; see [OCSF JSON collection](/observability/ocsf-json-export#output-location).
Docker and Podman use tmpfs for this directory; Kubernetes uses an `emptyDir` in the supervisor pod. Collect logs externally before rotation or teardown for durable retention. See [Accessing Logs](/observability/accessing-logs#direct-filesystem-access) for driver-specific access and storage lifetimes.
+24 -5
View File
@@ -7,7 +7,7 @@ description: "How to enable full OCSF JSON logging for SIEM integration, complia
keywords: "Generative AI, Cybersecurity, OCSF, JSON, SIEM, Compliance, Observability"
---
The [shorthand log format](/observability/logging) is optimized for humans and agents reading logs in real time. For machine consumption or SIEM integration, enable OCSF JSON output: one native JSON object per line. Collection is best-effort, not a guarantee of complete audit history.
The [shorthand log format](/observability/logging) is optimized for humans and agents reading logs in real time. For machine consumption or SIEM integration, enable OCSF JSON output: one native JSON object per record. Collection is best-effort, not a guarantee of complete audit history.
## Gateway Output
@@ -15,7 +15,7 @@ Configure `[openshell.gateway.ocsf_log]` in `gateway.toml` to collect gateway-or
The gateway writes OCSF events to a local JSONL file. Give each replica its own path and configure the shipper to follow renamed rotation segments. See [OCSF JSONL configuration](/how-it-works/gateways/configuration#ocsf-jsonl-output) for queue bounds, retention, loss metrics, and best-effort delivery limits.
This output is separate from the supervisor-local file described below. Enabling one does not enable the other. The gateway JSONL sink does not archive supervisor log lines pushed to the gateway; those lines carry OCSF shorthand text, not the full structured record.
This output is separate from the supervisor stderr and file output described below. Enabling one does not enable the other. The gateway JSONL sink does not archive supervisor log lines pushed to the gateway; those lines carry OCSF shorthand text, not the full structured record.
Gateway records use the configured gateway name for both `device.uid` and `device.name`. Replicas of one installation share that identity; `device.hostname` identifies the emitting replica. Assign distinct gateway names to installations whose events are collected together. Reusing a name, including the default name, groups those installations under the same identity. Renaming a gateway changes its audit identity.
@@ -69,7 +69,26 @@ do not suppress records in this explicitly enabled audit sink.
## Output Location
For sandbox supervisors, OCSF JSON records are written to `/var/log/openshell-ocsf.YYYY-MM-DD.log` in the supervisor's filesystem, separate from the workload. `openshell sandbox exec` cannot read those files. File output requires a writable `/var/log`; if the supervisor falls back to stderr-only logging, enabling `ocsf_json_enabled` does not create a JSONL sink.
When `ocsf_json_enabled=true`, sandbox supervisors emit full OCSF JSON to stderr alongside shorthand output:
```text
2026-10-07T23:21:19.000Z OCSF NET:OPEN [MED] DENIED ...
2026-10-07T23:21:19.000Z OCSF-JSON {"class_uid":4001,...}
```
The example payload is abbreviated. Each actual `OCSF-JSON` line contains one complete compact JSON object. Configure your collector to match the `OCSF-JSON` token after the UTC timestamp and decode everything after the following space as JSON. Select these lines to avoid ingesting the shorthand copy of each event. The JSON preserves the original `metadata.uid` and follows `ocsf_schema_version`; with that setting unset, it uses the full native schema.
Collect stderr through the supervisor's platform log interface:
```shell
docker logs --follow <supervisor-container>
podman logs --follow <supervisor-container>
kubectl -n <sandbox-namespace> logs <supervisor-pod> -c supervisor --follow
```
JSON stderr output works independently of writable log storage and the diagnostic log filter. Disabling the setting stops new JSON records. The console writer uses a bounded queue of 1,024 complete lines shared by diagnostics, shorthand, and JSON; when the queue is full, new lines are dropped rather than blocking enforcement. Delivery is best-effort, and retention depends on your runtime and external collector. The gateway log API and `openshell logs` continue to return shorthand.
When file logging is available, supervisors also write plain JSONL to `/var/log/openshell-ocsf.YYYY-MM-DD.log` in their own filesystem, separate from the workload. `openshell sandbox exec` cannot read those files. File output requires a writable `/var/log`; a missing file sink does not disable JSON stderr output.
This file contains supervisor-originated events, not the [sandbox-runtime events](/observability/accessing-logs#main-process-output) emitted into workload container logs.
@@ -85,7 +104,7 @@ For gateway-produced records, `device.os.name` identifies the OS running the gat
`metadata.uid` uniquely identifies an event and stays unchanged when that record is serialized again. Use `container.uid` to associate the event with its sandbox, not `metadata.uid`. Records without a sandbox association omit the container; unknown images are omitted rather than represented by an empty image name.
Each line is a complete OCSF v1.8.0 JSON object. Here is an example of a network connection event:
Each JSONL file line, or the payload after the stderr marker, is a complete OCSF v1.8.0 JSON object when no downgrade is configured. Here is an example of a network connection event:
```json
{
@@ -286,7 +305,7 @@ Sandbox events identify the sandbox environment with `device.type_id: 99` (Other
The shorthand format in `openshell.YYYY-MM-DD.log` and the JSON format in `openshell-ocsf.YYYY-MM-DD.log` are derived from the same OCSF events. The shorthand is a human-readable projection; the JSON is the full structured record when no schema downgrade is configured. When `ocsf_schema_version` is set, the JSON export is a lossy projection of the internal event model. Both formats are generated at the same time from the same event data.
Shorthand output is always active on stderr; its local file is available when file logging starts successfully. JSON file export is opt-in through `ocsf_json_enabled` and requires the supervisor's JSONL sink to start successfully.
Shorthand stderr output follows the diagnostic log filter; its local file is available when file logging starts successfully. JSON stderr and file export are opt-in through `ocsf_json_enabled`. File export requires the supervisor's JSONL sink to start successfully; stderr export is independent of that sink.
## Next Steps
+1
View File
@@ -39,6 +39,7 @@ PODMAN_CI_TESTS=(
podman_corporate_proxy
podman_gateway_start
podman_host_gateway
podman_oci_identity
provider_token_exchange
)
+140
View File
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e-docker")]
//! Full OCSF collection through supervisor stderr, without filesystem access.
use std::time::Duration;
use openshell_e2e::harness::binary::openshell_cmd;
use openshell_e2e::harness::sandbox::SandboxGuard;
use serde_json::Value;
use tokio::process::Command;
use tokio::time::{Instant, sleep};
async fn cli(args: &[&str]) -> String {
let output = openshell_cmd().args(args).output().await.unwrap();
assert!(
output.status.success(),
"CLI failed: {}",
String::from_utf8_lossy(&output.stderr)
);
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
}
async fn docker(args: &[&str]) -> (String, String) {
let output = Command::new("docker").args(args).output().await.unwrap();
assert!(
output.status.success(),
"Docker failed: {}",
String::from_utf8_lossy(&output.stderr)
);
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
fn json_records(stderr: &str) -> Vec<Value> {
stderr
.lines()
.filter_map(|line| {
let (_, payload) = line.split_once(" OCSF-JSON ")?;
assert!(!payload.contains('\x1b'));
Some(serde_json::from_str(payload).expect("complete JSON record on one line"))
})
.collect()
}
#[tokio::test]
async fn network_events_are_json_on_supervisor_stderr_only() {
let mut sandbox = SandboxGuard::create(&["--from", "nvcr.io/nvidia/base/ubuntu:24.04"])
.await
.expect("create sandbox");
let filter = format!("label=openshell.ai/sandbox-name={}", sandbox.name);
let (containers, _) = docker(&[
"ps",
"-q",
"--filter",
&filter,
"--filter",
"label=openshell.ai/isolation-role=supervisor",
])
.await;
let ids: Vec<_> = containers.lines().collect();
assert_eq!(ids.len(), 1, "expected one supervisor: {containers}");
let supervisor = ids[0];
let (_, before) = docker(&["logs", supervisor]).await;
assert!(json_records(&before).is_empty(), "JSON must be opt-in");
cli(&[
"settings",
"set",
&sandbox.name,
"--key",
"ocsf_json_enabled",
"--value",
"true",
])
.await;
let started = Instant::now();
let records = loop {
cli(&[
"sandbox",
"exec",
&sandbox.name,
"--timeout",
"10",
"--",
"bash",
"-c",
"echo > /dev/tcp/1.1.1.1/443 || true",
])
.await;
let (stdout, stderr) = docker(&["logs", supervisor]).await;
assert!(
!stdout.contains(" OCSF-JSON "),
"audit JSON should be on stderr"
);
let records = json_records(&stderr);
if records.iter().any(|record| {
record["class_uid"] == 4001
&& record["dst_endpoint"]["port"] == 443
&& record["action"] == "Denied"
}) {
assert!(
stderr.contains(" OCSF NET:OPEN "),
"shorthand remains available"
);
break records;
}
assert!(
started.elapsed() < Duration::from_secs(40),
"no JSON network denial in stderr: {stderr}"
);
sleep(Duration::from_secs(1)).await;
};
for record in records {
assert_eq!(record["metadata"]["version"], "1.8.0");
assert!(
record["metadata"]["uid"]
.as_str()
.is_some_and(|uid| !uid.is_empty())
);
assert_eq!(record["container"]["name"], sandbox.name);
}
// Allow the existing best-effort push batch to reach the gateway.
sleep(Duration::from_secs(2)).await;
let gateway_logs = cli(&["logs", &sandbox.name, "--source", "sandbox", "-n", "200"]).await;
assert!(gateway_logs.contains("NET:OPEN"));
assert!(
!gateway_logs.contains("OCSF-JSON"),
"gateway stream must remain unchanged"
);
sandbox.cleanup().await;
}
+17 -5
View File
@@ -10,7 +10,8 @@
//! sandbox from its mutable tag, and verifies both the child identity and the
//! image ID recorded on the real sandbox container. This exercises the Podman
//! API inspect → protected metadata → create path rather than only its unit
//! serialization boundaries.
//! serialization boundaries. Workspace behavior shared with Docker is covered
//! by the `oci-image` feature suite in `tests/suites/features`.
use std::process::Stdio;
@@ -54,7 +55,16 @@ impl ImageGuard {
let containerfile = context.path().join("Containerfile");
std::fs::write(
&containerfile,
format!("FROM {BASE_IMAGE}\nUSER {OCI_UID}:{OCI_GID}\n"),
format!(
r"FROM {BASE_IMAGE}
USER 0:0
RUN mkdir -p /home/app/project && \
chown {OCI_UID}:{OCI_GID} /home/app /home/app/project && \
chmod 0700 /home/app /home/app/project
WORKDIR /home/app/project
USER {OCI_UID}:{OCI_GID}
"
),
)
.map_err(|err| format!("write Containerfile: {err}"))?;
@@ -248,10 +258,10 @@ async fn podman_uses_oci_identity_and_inspected_image_id() {
.exec(&[
"sh",
"-c",
"set -eu; stat -c 'workspace-owner=%u:%g' /sandbox; touch /sandbox/probe; rm /sandbox/probe; echo podman-workspace-write-ok",
"set -eu; test \"$(pwd -P)\" = /home/app/project; test ! -e /sandbox; stat -c 'workspace-owner=%u:%g' .; touch probe; rm probe; echo podman-workspace-write-ok",
])
.await
.expect("OCI workload should be able to write to the managed workspace");
.expect("OCI workload should be able to write to its image workspace");
assert!(
workspace_output.contains(&format!("workspace-owner={OCI_UID}:{OCI_GID}")),
"expected workspace owner {OCI_UID}:{OCI_GID}:\n{workspace_output}"
@@ -277,7 +287,7 @@ async fn assert_isolated_pair(image: &ImageGuard, sandbox: &SandboxGuard, contai
assert_eq!(
workload_user,
format!("{OCI_UID}:{OCI_GID}"),
"the workload must start directly as the final OCI identity"
"a custom OCI workspace must start directly as the final identity"
);
let supervisor_user = run_engine(
&image.engine,
@@ -317,6 +327,8 @@ async fn assert_isolated_pair(image: &ImageGuard, sandbox: &SandboxGuard, contai
.unwrap();
assert!(!mounts.contains("/etc/openshell/tls"));
assert!(!mounts.contains("/.openshell/supervisor"));
assert!(!mounts.lines().any(|path| path == "/home/app/project"));
assert!(!mounts.lines().any(|path| path == "/sandbox"));
let posture = sandbox.exec(&["sh", "-c", "set -eu; awk '/^CapEff:|^CapBnd:|^NoNewPrivs:/ {print}' /proc/self/status; test ! -r /.openshell/channel/sandbox/server.key; test ! -r /.openshell/supervisor/runtime-descriptor.json"]).await.expect("workload cannot read either control credential set");
assert!(posture.contains("0000000000000000"));
}
+465 -2
View File
@@ -10,8 +10,8 @@ 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::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -42,6 +42,10 @@ 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],
@@ -270,6 +274,13 @@ 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();
@@ -683,6 +694,68 @@ binaries:
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
@@ -811,6 +884,7 @@ async fn create_provider_instance(
#[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"),
@@ -882,6 +956,12 @@ async fn podman_provider_token_exchange_injects_independent_grants_across_sandbo
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!(
@@ -918,3 +998,386 @@ async fn podman_provider_token_exchange_injects_independent_grants_across_sandbo
"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"
);
}
+12
View File
@@ -1142,6 +1142,7 @@ dependencies = [
"noyalib",
"serde",
"serde_json",
"serde_path_to_error",
"thiserror",
]
@@ -1762,6 +1763,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
+12
View File
@@ -1211,6 +1211,7 @@ dependencies = [
"noyalib",
"serde",
"serde_json",
"serde_path_to_error",
"thiserror",
]
@@ -1761,6 +1762,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
+7 -1
View File
@@ -2317,12 +2317,18 @@ message ProviderProfileCredential {
ProviderCredentialRefresh refresh = 8;
string path_template = 9;
ProviderCredentialTokenGrant token_grant = 10;
// Output-only gateway-derived endpoint authorities for resolved token grants.
// For inspected L7 requests, empty means no endpoint may obtain this grant.
// L4 injection uses selectors without checking these authorities.
// Profile-authored values are ignored; normal policies name the originating
// provider endpoint, while a global policy uses its own endpoint authorities.
repeated string token_grant_owners = 11;
// Opt in to an opaque identity-bound environment placeholder for every env
// var declared by this credential. Use this only when an external credential
// updater requires one placeholder across value-only provider revisions.
// Gateway-managed refresh and token-grant credentials cannot opt in because
// they require identity-bearing handles for authorization-epoch revocation.
bool stable_placeholder = 11;
bool stable_placeholder = 12;
}
enum ProviderCredentialRefreshStrategy {
+4
View File
@@ -230,6 +230,10 @@ message NetworkEndpoint {
// Internal gateway-derived marker indicating that this endpoint belongs to
// an attached credentialed provider. User-authored values are ignored.
bool provider_credentialed = 26;
// Internal gateway-derived identity of this endpoint's token-grant authority.
// It binds the provider record and complete profile policy, or the active
// global policy, to this endpoint. User-authored values are ignored.
string token_grant_owner = 27;
}
// MCP options are grouped so MCP-specific policy can grow without adding more
@@ -229,7 +229,12 @@ func TestConverterCoversAllProtoFields_NetworkEndpoint(t *testing.T) {
"credential_binding": true,
}
assertAllFieldsCovered(t, (&sandboxpb.NetworkEndpoint{}).ProtoReflect().Descriptor(), handled, nil)
// The gateway derives this authority from the effective policy. It is
// available through the raw API, not authored SDK endpoint settings.
skipped := fieldSet{
"token_grant_owner": true,
}
assertAllFieldsCovered(t, (&sandboxpb.NetworkEndpoint{}).ProtoReflect().Descriptor(), handled, skipped)
}
func TestConverterCoversAllProtoFields_L7Allow(t *testing.T) {
@@ -337,7 +342,12 @@ func TestConverterCoversAllProtoFields_ProviderProfileCredential(t *testing.T) {
"stable_placeholder": true,
}
assertAllFieldsCovered(t, (&pb.ProviderProfileCredential{}).ProtoReflect().Descriptor(), handled, nil)
// Resolved grant authorities belong to gateway-to-supervisor delivery,
// not authored provider profiles. They remain available in the raw API.
skipped := fieldSet{
"token_grant_owners": true,
}
assertAllFieldsCovered(t, (&pb.ProviderProfileCredential{}).ProtoReflect().Descriptor(), handled, skipped)
}
func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrant(t *testing.T) {
+18 -4
View File
@@ -9120,12 +9120,18 @@ type ProviderProfileCredential struct {
Refresh *ProviderCredentialRefresh `protobuf:"bytes,8,opt,name=refresh,proto3" json:"refresh,omitempty"`
PathTemplate string `protobuf:"bytes,9,opt,name=path_template,json=pathTemplate,proto3" json:"path_template,omitempty"`
TokenGrant *ProviderCredentialTokenGrant `protobuf:"bytes,10,opt,name=token_grant,json=tokenGrant,proto3" json:"token_grant,omitempty"`
// Output-only gateway-derived endpoint authorities for resolved token grants.
// For inspected L7 requests, empty means no endpoint may obtain this grant.
// L4 injection uses selectors without checking these authorities.
// Profile-authored values are ignored; normal policies name the originating
// provider endpoint, while a global policy uses its own endpoint authorities.
TokenGrantOwners []string `protobuf:"bytes,11,rep,name=token_grant_owners,json=tokenGrantOwners,proto3" json:"token_grant_owners,omitempty"`
// Opt in to an opaque identity-bound environment placeholder for every env
// var declared by this credential. Use this only when an external credential
// updater requires one placeholder across value-only provider revisions.
// Gateway-managed refresh and token-grant credentials cannot opt in because
// they require identity-bearing handles for authorization-epoch revocation.
StablePlaceholder bool `protobuf:"varint,11,opt,name=stable_placeholder,json=stablePlaceholder,proto3" json:"stable_placeholder,omitempty"`
StablePlaceholder bool `protobuf:"varint,12,opt,name=stable_placeholder,json=stablePlaceholder,proto3" json:"stable_placeholder,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -9230,6 +9236,13 @@ func (x *ProviderProfileCredential) GetTokenGrant() *ProviderCredentialTokenGran
return nil
}
func (x *ProviderProfileCredential) GetTokenGrantOwners() []string {
if x != nil {
return x.TokenGrantOwners
}
return nil
}
func (x *ProviderProfileCredential) GetStablePlaceholder() bool {
if x != nil {
return x.StablePlaceholder
@@ -18671,7 +18684,7 @@ const file_openshell_proto_rawDesc = "" +
"grant_type\x18\b \x01(\x0e2..openshell.v1.ProviderCredentialTokenGrantTypeR\tgrantType\x12[\n" +
"\rsubject_token\x18\t \x01(\v26.openshell.v1.ProviderCredentialTokenGrantSubjectTokenR\fsubjectToken\x120\n" +
"\x14requested_token_type\x18\n" +
" \x01(\tR\x12requestedTokenTypeJ\x04\b\x04\x10\x05R\x11cache_ttl_seconds\"\xcd\x03\n" +
" \x01(\tR\x12requestedTokenTypeJ\x04\b\x04\x10\x05R\x11cache_ttl_seconds\"\xfb\x03\n" +
"\x19ProviderProfileCredential\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" +
@@ -18687,8 +18700,9 @@ const file_openshell_proto_rawDesc = "" +
"\rpath_template\x18\t \x01(\tR\fpathTemplate\x12K\n" +
"\vtoken_grant\x18\n" +
" \x01(\v2*.openshell.v1.ProviderCredentialTokenGrantR\n" +
"tokenGrant\x12-\n" +
"\x12stable_placeholder\x18\v \x01(\bR\x11stablePlaceholder\"\x8d\x01\n" +
"tokenGrant\x12,\n" +
"\x12token_grant_owners\x18\v \x03(\tR\x10tokenGrantOwners\x12-\n" +
"\x12stable_placeholder\x18\f \x01(\bR\x11stablePlaceholder\"\x8d\x01\n" +
"!ProviderCredentialRefreshMaterial\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" +
+16 -4
View File
@@ -911,8 +911,12 @@ type NetworkEndpoint struct {
// Internal gateway-derived marker indicating that this endpoint belongs to
// an attached credentialed provider. User-authored values are ignored.
ProviderCredentialed bool `protobuf:"varint,26,opt,name=provider_credentialed,json=providerCredentialed,proto3" json:"provider_credentialed,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Internal gateway-derived identity of this endpoint's token-grant authority.
// It binds the provider record and complete profile policy, or the active
// global policy, to this endpoint. User-authored values are ignored.
TokenGrantOwner string `protobuf:"bytes,27,opt,name=token_grant_owner,json=tokenGrantOwner,proto3" json:"token_grant_owner,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NetworkEndpoint) Reset() {
@@ -1127,6 +1131,13 @@ func (x *NetworkEndpoint) GetProviderCredentialed() bool {
return false
}
func (x *NetworkEndpoint) GetTokenGrantOwner() string {
if x != nil {
return x.TokenGrantOwner
}
return ""
}
// MCP options are grouped so MCP-specific policy can grow without adding more
// top-level NetworkEndpoint fields. OpenShell owns the supported revision
// profiles instead of treating dependency enums as the policy contract.
@@ -2319,7 +2330,7 @@ const file_sandbox_proto_rawDesc = "" +
"\ainclude\x18\x01 \x03(\tR\ainclude\x12\x18\n" +
"\aexclude\x18\x02 \x03(\tR\aexclude\"6\n" +
"\x18NetworkCredentialBinding\x12\x1a\n" +
"\bprovider\x18\x01 \x01(\tR\bprovider\"\xdb\v\n" +
"\bprovider\x18\x01 \x01(\tR\bprovider\"\x87\f\n" +
"\x0fNetworkEndpoint\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" +
"\x04port\x18\x02 \x01(\rR\x04port\x12\x1a\n" +
@@ -2349,7 +2360,8 @@ const file_sandbox_proto_rawDesc = "" +
"\x03mcp\x18\x17 \x01(\v2 .openshell.sandbox.v1.McpOptionsR\x03mcp\x12]\n" +
"\x12credential_binding\x18\x18 \x01(\v2..openshell.sandbox.v1.NetworkCredentialBindingR\x11credentialBinding\x12B\n" +
"\x1dallow_uninspected_credentials\x18\x19 \x01(\bR\x1ballowUninspectedCredentials\x123\n" +
"\x15provider_credentialed\x18\x1a \x01(\bR\x14providerCredentialed\x1ar\n" +
"\x15provider_credentialed\x18\x1a \x01(\bR\x14providerCredentialed\x12*\n" +
"\x11token_grant_owner\x18\x1b \x01(\tR\x0ftokenGrantOwner\x1ar\n" +
"\x1cGraphqlPersistedQueriesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12<\n" +
"\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.GraphqlOperationR\x05value:\x028\x01\"\xd2\x01\n" +
+9 -2
View File
@@ -249,7 +249,13 @@ errors as connectivity, authorization, or lifecycle failures.
The sandbox container's log holds the sandbox runtime's warnings and the
main process's stdout and stderr when it runs without a TTY. The supervisor
container's log holds supervisor diagnostics.
container's log holds supervisor diagnostics and OCSF shorthand. With
`ocsf_json_enabled=true`, it also contains compact `OCSF-JSON` records for
log collectors. Read these through `docker logs <supervisor-container>`;
`docker cp` does not expose the live `/var/log` tmpfs, and workload exec
accesses a separate filesystem. See the published
[OCSF JSON export guide](https://docs.nvidia.com/openshell/latest/observability/ocsf-json-export)
for the marker format and delivery limits.
```bash
docker info
@@ -293,7 +299,8 @@ Common findings:
- Sandbox image missing or pull denied: verify image reference and registry credentials.
- Sandbox fails before readiness with an identity-resolution error: inspect the image's OCI `USER` and matching `/etc/passwd` and `/etc/group` entries, or explicitly set both process identity fields in policy. Numeric workload identities `1` through `4294967294` are accepted; root, the invalid identity sentinel, and missing identities are rejected.
- Sandbox fails before readiness with an OCI workspace validation error: inspect the image's `WorkingDir` using the immutable image ID reported by the gateway. Empty, `/`, and explicit `/sandbox` use the managed `/sandbox` compatibility workspace. Any other workdir must be an absolute normalized directory with no symlink components; the final policy UID, primary GID, and supplementary groups must pass the kernel's effective traverse/write checks, including POSIX ACL and LSM decisions. OpenShell does not create, chown, or chmod a non-default image workdir.
- Docker also rejects an image `VOLUME` that covers the workdir or one of its parents because the runtime would mask the immutable path before validation. Move the `VOLUME` below the workspace or remove the declaration.
- Docker and Podman also reject an image `VOLUME` or driver mount that covers the workdir or one of its parents because the runtime would mask the immutable path before validation. Move the `VOLUME` below the workspace or remove the declaration.
- Custom Docker and Podman workspace changes live in the workload container's writable layer, not a managed named volume. Stop/start of the same container retains them; removing or replacing that container does not. Export files you need before deleting the sandbox.
- A workdir rejected as a special filesystem or OpenShell control-path collision cannot be made valid with permissions. Move the image workdir away from kernel-backed mounts and the concrete supervisor, TLS, token, runtime, and socket paths named in the error.
- Local Docker gateway setup cannot copy `openshell-sandbox` after exporting a supervisor image: the sandbox runtime and supervisor are separate artifacts. The runtime image must provide `/openshell-sandbox`; the supervisor image provides `/openshell-supervisor`.
- Docker driver cannot initialize because it cannot find `openshell-sandbox`: verify the sibling binary next to `openshell-gateway`, or that the configured `sandbox_runtime_image` contains `/openshell-sandbox`.
+2
View File
@@ -872,6 +872,8 @@ openshell settings set --global --key ocsf_json_enabled --value true
openshell settings set --global --key ocsf_schema_version --value "1.1"
```
Enabling `ocsf_json_enabled` adds compact `OCSF-JSON` records to the supervisor's stderr, alongside shorthand, and to its JSONL file when file storage is available. Collect these records through the supervisor's platform logs (`docker logs`, `podman logs`, or `kubectl logs`); `openshell logs` still serves shorthand. Match the `OCSF-JSON` marker after the timestamp and parse the remainder as JSON. See the published [OCSF JSON export guide](https://docs.nvidia.com/openshell/latest/observability/ocsf-json-export) for delivery and retention limits.
Global mutations prompt for confirmation. Use `--yes` only in reviewed automation.
`policy_validation_failure_mode` is gateway startup configuration, not a
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Run a scan, retaining only allowlisted diagnostics suitable for public CI."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import stat
import subprocess
import time
from pathlib import Path
def read_document(path: Path) -> tuple[dict, str]:
# Never echo parser errors, paths, or arbitrary scanner-generated text.
try:
if not stat.S_ISREG(path.lstat().st_mode):
return {}, "invalid"
# Check the opened file too: the path may change after lstat().
flags = (
os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
)
with os.fdopen(os.open(path, flags), "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
return {}, "invalid"
data = stream.read(4 * 1024 * 1024 + 1)
if len(data) > 4 * 1024 * 1024:
return {}, "oversized"
document = json.loads(data)
if isinstance(document, dict):
return document, "available"
except FileNotFoundError:
return {}, "missing"
except (OSError, ValueError, RecursionError):
pass
return {}, "invalid"
def coverage_summary(scan_dir: Path) -> dict:
coverage, coverage_state = read_document(scan_dir / "coverage.json")
manifest, manifest_state = read_document(scan_dir / "scan-manifest.json")
scan = manifest.get("scan")
status = scan.get("status") if isinstance(scan, dict) else None
completeness = coverage.get("completeness")
surfaces = coverage.get("surfaces")
result = {
"coverage_document": coverage_state,
"manifest_document": manifest_state,
"scan_status": status
if status in ("completed", "failed", "canceled", "interrupted")
else "unknown",
"coverage": completeness
if completeness in ("complete", "partial", "unknown")
else "unknown",
"surfaces_total": len(surfaces) if isinstance(surfaces, list) else None,
"surfaces_needing_follow_up": sum(
isinstance(surface, dict)
and surface.get("disposition") == "needs_follow_up"
for surface in surfaces
)
if isinstance(surfaces, list)
else None,
}
for field, label in (
("deferred", "deferred_items"),
("explicitExclusions", "explicit_exclusions"),
("openQuestions", "open_questions"),
):
value = coverage.get(field)
result[label] = len(value) if isinstance(value, list) else None
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--scan-dir", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command:
parser.error("a scanner command is required")
args.scan_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
before = shutil.disk_usage(args.scan_dir)
minimum_free = before.free
started = time.monotonic()
next_progress = started + 60
exit_code = 127
# Raw output may contain findings or provider errors with sensitive text.
# Discard it without consuming disk space or populating SCAN_DIR.
try:
with subprocess.Popen(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
) as process:
while True:
try:
exit_code = process.wait(timeout=5)
break
except subprocess.TimeoutExpired:
minimum_free = min(
minimum_free, shutil.disk_usage(args.scan_dir).free
)
if time.monotonic() >= next_progress:
print(
f"Scan running: elapsed_seconds={round(time.monotonic() - started)} "
f"disk_free_bytes_min_sampled={minimum_free}",
flush=True,
)
next_progress = time.monotonic() + 60
if exit_code < 0:
exit_code = 128 - exit_code
except OSError:
print("::error::Could not execute the security scanner.")
after = shutil.disk_usage(args.scan_dir)
report = {
"schema_version": 1,
"exit_code": exit_code,
"elapsed_seconds": round(time.monotonic() - started),
"disk_total_bytes": before.total,
"disk_free_bytes_before": before.free,
"disk_free_bytes_min_sampled": min(minimum_free, after.free),
"disk_free_bytes_after": after.free,
"disk_sample_interval_seconds": 5,
**coverage_summary(args.scan_dir),
}
rendered = json.dumps(report, indent=2) + "\n"
args.output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"):
with Path(summary_path).open("a", encoding="utf-8") as summary:
summary.write("### Codex Security execution diagnostics\n\n")
summary.write(f"```json\n{rendered}```\n\n")
summary.write(
"Only fixed states and aggregate counts are published. "
"Raw reports, findings, paths, reasons, and scanner logs are omitted. "
"Disk minimum is sampled every 5 seconds, not a continuous measurement.\n"
)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,190 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from codex_security_diagnostics import coverage_summary
SCRIPT = Path(__file__).with_name("codex_security_diagnostics.py")
SENSITIVE = "PRIVATE_FINDING_secret_token_source_code"
@pytest.mark.parametrize("exit_code", [0, 2, 137])
def test_public_report_omits_sensitive_text_and_preserves_exit(tmp_path, exit_code):
scan = tmp_path / "scan"
scan.mkdir()
(scan / "coverage.json").write_text(
json.dumps(
{
"completeness": "partial",
"includePaths": [SENSITIVE],
"surfaces": [
{"disposition": "needs_follow_up", "notes": SENSITIVE},
{"disposition": SENSITIVE, "label": SENSITIVE},
],
"deferred": [{"reason": SENSITIVE, "paths": [SENSITIVE]}],
"explicitExclusions": [{"reason": SENSITIVE}],
"openQuestions": [{"question": SENSITIVE}],
SENSITIVE: SENSITIVE,
}
)
)
(scan / "scan-manifest.json").write_text(
json.dumps({"scan": {"status": "completed", "threatModel": SENSITIVE}})
)
(scan / "findings.json").write_text(SENSITIVE)
output = tmp_path / "diagnostics.json"
summary = tmp_path / "summary.md"
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--scan-dir",
str(scan),
"--output",
str(output),
"--",
sys.executable,
"-c",
f"import sys; print({SENSITIVE!r}); "
f"print({SENSITIVE!r}, file=sys.stderr); sys.exit({exit_code})",
],
env={**os.environ, "GITHUB_STEP_SUMMARY": str(summary)},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == exit_code
for public_output in (
result.stdout,
result.stderr,
output.read_text(),
summary.read_text(),
):
assert SENSITIVE not in public_output
report = json.loads(output.read_text())
assert report["coverage"] == "partial"
assert report["deferred_items"] == 1
assert report["surfaces_needing_follow_up"] == 1
assert report["disk_free_bytes_min_sampled"] <= report["disk_free_bytes_before"]
def test_scanner_output_does_not_exhaust_file_size_limit(tmp_path):
pytest.importorskip("resource")
output = tmp_path / "diagnostics.json"
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--scan-dir",
str(tmp_path / "scan"),
"--output",
str(output),
"--",
sys.executable,
"-c",
"import os, resource; "
"resource.setrlimit(resource.RLIMIT_FSIZE, (1024, 1024)); "
"os.write(1, b'x' * 2048); os.write(1, b'x' * 2048); "
"os.write(2, b'x' * 2048); os.write(2, b'x' * 2048)",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert json.loads(output.read_text())["exit_code"] == 0
@pytest.mark.parametrize("value", [SENSITIVE, {}, [], None, 7, True])
def test_unknown_enum_values_are_not_published(tmp_path, value):
(tmp_path / "coverage.json").write_text(json.dumps({"completeness": value}))
(tmp_path / "scan-manifest.json").write_text(
json.dumps({"scan": {"status": value}})
)
report = coverage_summary(tmp_path)
assert report["coverage"] == "unknown"
assert report["scan_status"] == "unknown"
assert SENSITIVE not in json.dumps(report)
def test_missing_invalid_and_symlinked_reports_do_not_echo_contents(tmp_path):
assert coverage_summary(tmp_path)["coverage_document"] == "missing"
path = tmp_path / "coverage.json"
path.write_text(SENSITIVE)
assert coverage_summary(tmp_path)["coverage_document"] == "invalid"
path.unlink()
path.symlink_to(tmp_path / "scan-manifest.json")
assert coverage_summary(tmp_path)["coverage_document"] == "invalid"
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="requires named pipes")
@pytest.mark.parametrize(
("filename", "field"),
[
("coverage.json", "coverage_document"),
("scan-manifest.json", "manifest_document"),
],
)
def test_fifo_report_does_not_block_diagnostics(tmp_path, filename, field):
scan = tmp_path / "scan"
scan.mkdir()
os.mkfifo(scan / filename)
output = tmp_path / "diagnostics.json"
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--scan-dir",
str(scan),
"--output",
str(output),
"--",
sys.executable,
"-c",
"raise SystemExit(2)",
],
capture_output=True,
text=True,
check=False,
timeout=5,
)
assert result.returncode == 2
report = json.loads(output.read_text())
assert report["exit_code"] == 2
assert report[field] == "invalid"
def test_scan_output_directory_stays_empty_until_scanner_writes(tmp_path):
scan = tmp_path / "scan"
output = tmp_path / "diagnostics.json"
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--scan-dir",
str(scan),
"--output",
str(output),
"--",
sys.executable,
"-c",
"import pathlib, sys; assert not list(pathlib.Path(sys.argv[1]).iterdir())",
str(scan),
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert json.loads(output.read_text())["coverage_document"] == "missing"
+6
View File
@@ -18,6 +18,7 @@ depends = [
"test:packaging-assets",
"test:qualification-summary",
"test:codex-security-release-range",
"test:codex-security-diagnostics",
"test:docs-website",
"test:docs-nav",
"test:maintainer-approval",
@@ -79,6 +80,11 @@ description = "Test Codex Security release-range resolution"
run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/codex_security_range_test.py"
hide = true
["test:codex-security-diagnostics"]
description = "Test public Codex Security diagnostics and sensitive-data filtering"
run = "uv run --no-project --with pytest pytest tasks/scripts/codex_security_diagnostics_test.py"
hide = true
["test:maintainer-approval"]
description = "Test the maintainer approval gate and change-alert tools"
run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/check_maintainer_approval_test.py tasks/scripts/alert_maintainer_change_test.py"