Skip to content
Merged
32 changes: 32 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,38 @@ the structured 403 and authors the narrowest rule. Mechanistically mapping L7
would either over-broaden rules or require path-templating logic that rots
quickly.

## Policy Revision Acknowledgement

When the supervisor loads a sandbox-scoped policy from the gateway, it retains
the version, hash, source, and configuration revision returned with that exact
policy snapshot. After the OPA engine is built successfully, the supervisor
reports that revision as `LOADED`, which advances
`SandboxStatus.current_policy_version` and moves the revision out of `Pending`.
If policy construction fails, it reports the captured revision as `FAILED` with
the original construction error. It never infers revision identity by comparing
policy structure.

This holds even when the initial policy is enriched with baseline paths during
startup: the enriched revision the supervisor synced back to the gateway is the
revision it acknowledges, so a successfully constructed initial policy never
remains `Pending`. If the first poll returns a different revision, the supervisor
processes it through the normal reload path instead of treating it as already
loaded.

Policy status delivery uses a FIFO background worker. Retryable delivery
failures retain the ordered update and retry with capped exponential backoff;
terminal errors are logged and discarded. The outbox is nonblocking and does
not discard updates because of a fixed queue capacity, so status endpoint
outages cannot block policy polling, enforcement, settings, or provider
refreshes and cannot permanently lose the initial acknowledgement.

Only sandbox-scoped revisions (`PolicySource::Sandbox`, version greater than
zero) are acknowledged. Global policies and local-file development policies do
not use the sandbox revision API and produce no acknowledgement. When explicit
local Rego and data files are configured, the supervisor continues polling the
gateway for settings and provider refreshes but never replaces the local OPA
engine with a gateway policy revision.

## Failure Behavior

- If gateway config polling fails, the sandbox keeps its last-known-good policy.
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ async fn gateway_add_mtls_loopback_explicit_name_does_not_fallback_to_openshell_

#[tokio::test]
async fn cli_connects_with_client_cert() {
let _env = EnvVarGuard::set(&[]);
install_rustls_provider();

let (ca, ca_key) = build_ca();
Expand Down Expand Up @@ -741,6 +742,7 @@ async fn run_server_no_client_auth(

#[tokio::test]
async fn cli_connects_with_gateway_insecure() {
let _env = EnvVarGuard::set(&[]);
install_rustls_provider();

let (ca, ca_key) = build_ca();
Expand Down
73 changes: 54 additions & 19 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,26 +573,46 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result<Option<Pro
fetch_policy_with_client(&mut client, sandbox_id).await
}

/// Fetch sandbox policy using an existing client connection.
async fn fetch_policy_with_client(
/// Fetch the authoritative policy and revision metadata in one response.
///
/// Callers that must acknowledge the exact revision they loaded should retain
/// this snapshot instead of re-fetching metadata after policy construction.
pub async fn fetch_settings_snapshot(
endpoint: &str,
sandbox_id: &str,
) -> Result<SettingsPollResult> {
debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Connecting to fetch OpenShell settings snapshot");
let mut client = connect(endpoint).await?;
fetch_settings_snapshot_with_client(&mut client, sandbox_id).await
}

async fn fetch_settings_snapshot_with_client(
client: &mut OpenShellClient<AuthedChannel>,
sandbox_id: &str,
) -> Result<Option<ProtoSandboxPolicy>> {
) -> Result<SettingsPollResult> {
let response = client
.get_sandbox_config(GetSandboxConfigRequest {
sandbox_id: sandbox_id.to_string(),
})
.await
.into_diagnostic()?;

let inner = response.into_inner();
Ok(settings_poll_result(response.into_inner()))
}

/// Fetch sandbox policy using an existing client connection.
async fn fetch_policy_with_client(
client: &mut OpenShellClient<AuthedChannel>,
sandbox_id: &str,
) -> Result<Option<ProtoSandboxPolicy>> {
let snapshot = fetch_settings_snapshot_with_client(client, sandbox_id).await?;

// version 0 with no policy means the sandbox was created without one.
if inner.version == 0 && inner.policy.is_none() {
if snapshot.version == 0 && snapshot.policy.is_none() {
return Ok(None);
}

Ok(Some(inner.policy.ok_or_else(|| {
Ok(Some(snapshot.policy.ok_or_else(|| {
miette::miette!("Server returned non-zero version but empty policy")
})?))
}
Expand Down Expand Up @@ -661,6 +681,18 @@ pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPol
sync_policy_with_client(&mut client, sandbox, policy).await
}

/// Sync an enriched policy and return the authoritative revision snapshot.
pub async fn sync_policy_and_fetch_snapshot(
endpoint: &str,
sandbox_id: &str,
sandbox: &str,
policy: &ProtoSandboxPolicy,
) -> Result<SettingsPollResult> {
let mut client = connect(endpoint).await?;
sync_policy_with_client(&mut client, sandbox, policy).await?;
fetch_settings_snapshot_with_client(&mut client, sandbox_id).await
}

/// Fetch provider environment variables for a sandbox from `OpenShell` server via gRPC.
///
/// Returns a map of environment variable names to values derived from provider
Expand Down Expand Up @@ -700,6 +732,7 @@ pub struct CachedOpenShellClient {
}

/// Settings poll result returned by [`CachedOpenShellClient::poll_settings`].
#[derive(Clone, Debug)]
pub struct SettingsPollResult {
pub policy: Option<ProtoSandboxPolicy>,
pub version: u32,
Expand All @@ -713,6 +746,20 @@ pub struct SettingsPollResult {
pub provider_env_revision: u64,
}

fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult {
SettingsPollResult {
policy: inner.policy,
version: inner.version,
policy_hash: inner.policy_hash,
config_revision: inner.config_revision,
policy_source: PolicySource::try_from(inner.policy_source)
.unwrap_or(PolicySource::Unspecified),
settings: inner.settings,
global_policy_version: inner.global_policy_version,
provider_env_revision: inner.provider_env_revision,
}
}

pub struct ProviderEnvironmentResult {
pub environment: HashMap<String, String>,
pub provider_env_revision: u64,
Expand Down Expand Up @@ -743,19 +790,7 @@ impl CachedOpenShellClient {
.await
.into_diagnostic()?;

let inner = response.into_inner();

Ok(SettingsPollResult {
policy: inner.policy,
version: inner.version,
policy_hash: inner.policy_hash,
config_revision: inner.config_revision,
policy_source: PolicySource::try_from(inner.policy_source)
.unwrap_or(PolicySource::Unspecified),
settings: inner.settings,
global_policy_version: inner.global_policy_version,
provider_env_revision: inner.provider_env_revision,
})
Ok(settings_poll_result(response.into_inner()))
}

/// Submit denial summaries and/or agent-authored proposals for policy analysis.
Expand Down
Loading
Loading