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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ transport status, metadata, and unknown details alongside decoded fields.
SDK deletion waits recognize missing-resource status through typed error wrappers
without suppressing other failures.

Ordinary user-callable unary mutations explicitly opt into durable request
admission when the client supplies a UUID. Typed adapters
check current authorization before looking up a caller/method/workspace-scoped
key. The payload fingerprint excludes that UUID and canonicalizes protobuf maps.
An atomic, quota-checked insert chooses one executor; owned execution survives
client cancellation. Success is persisted before acknowledgment. Errors or
interruption leave permanent unresolved claims, never stealable leases.

Admission rows live outside user workspace namespaces and are bounded per caller.
Successes expire after 24 hours; cleanup uses the unique admission incarnation
and version so an old cleaner cannot delete a new attempt. Replay stores only
resource references and reviewed public scalar/diagnostic receipts, never
credential-bearing response snapshots. It checks original identities and current
authorization and never substitutes a same-name resource. Sandbox responses are
live projections of the original UUID; normal status reconciliation does not
invalidate replay. Refresh status additionally requires the original grant epoch
and no deletion timestamp, including a timestamp at the Unix epoch.
Other resource projections retain exact-version guards. Terminal delete receipts
do not require the deleted target or parent to remain present.

Sandbox, service, provider/profile, and policy/config adapters use keyed payload
fingerprints derived from existing gateway JWT or primary TLS private material.
Replicas must share that material; missing keys or key changes fail closed without
changing admission identity. Workspace/template adapters retain their original
format. Intercepted requests carry the original decoded payload only in a private
in-memory extension. Replay reauthorizes original and current effective scopes,
requires the same effective payload, and reruns current interceptor validation.
Interceptors cannot mutate the request UUID. Server-marked replay suppresses
post-commit observation, which remains best-effort rather than an outbox.
Credential capabilities and streaming execution require separate contracts.

The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
The default local single-user deployment mode is mTLS user authentication:
clients present a certificate signed by the local deployment CA, and the
Expand Down Expand Up @@ -391,7 +422,8 @@ Missing targets return `NOT_FOUND` unless `allow_missing` explicitly requests
failures remain errors. Already-revoked sessions complete without another write
after current authorization. The removed response booleans are reserved by name
and number; this coordinated pre-1.0 API change does not alter durable schemas.
It does not add request deduplication or identity preconditions for later retries.
The outcome alone does not provide request deduplication. Opted-in unary methods
require a request UUID for the admission contract.

| Dual-purpose encoded root | Current decision |
|---|---|
Expand Down Expand Up @@ -442,6 +474,14 @@ populate `scope`, `version`, `status`, `dedup_key`, and `hit_count` so the
gateway can efficiently fetch the latest policy, track load status, and manage
advisor drafts without creating resource-specific tables.

Mutation admission uses a private, version-tagged JSON envelope in the same
object store. Its identity namespace stays stable across format changes, and an
unknown format fails closed. It contains explicit typed receipts, not arbitrary
public response payloads, and is not part of the protobuf storage closure.
Workspace create/delete admissions include the requested workspace name in the
key, but omit a workspace UUID guard. Different names have independent request-ID
namespaces; deletion receipts remain replayable after the target disappears.

Each sandbox policy revision stores the complete provenance annotation map
supplied with that update. The revision payload is the authoritative immutable
record; sandbox metadata receives the same annotations only as a convenience
Expand Down
16 changes: 16 additions & 0 deletions crates/openshell-cli/src/commands/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub async fn sandbox_provider_attach(

let response = match client
.attach_sandbox_provider(AttachSandboxProviderRequest {
request_id: String::new(),
sandbox_name: name.to_string(),
provider_name: provider.to_string(),
expected_resource_version: resource_version,
Expand Down Expand Up @@ -165,6 +166,7 @@ pub async fn sandbox_provider_detach(

let response = match client
.detach_sandbox_provider(DetachSandboxProviderRequest {
request_id: String::new(),
sandbox_name: name.to_string(),
provider_name: provider.to_string(),
expected_resource_version: resource_version,
Expand Down Expand Up @@ -477,6 +479,7 @@ async fn auto_create_provider(
if let Some(exact_name) = preferred_name {
// Explicit name: create with exactly that name, no retries.
let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -525,6 +528,7 @@ async fn auto_create_provider(
};

let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -676,6 +680,7 @@ async fn rollback_provider_create_after_gcloud_adc_failure(
) -> Result<()> {
match client
.delete_provider(DeleteProviderRequest {
request_id: String::new(),
allow_missing: true,
name: provider_name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -1107,6 +1112,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->

let response = client
.create_provider(CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -1153,6 +1159,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->

if let Err(configure_err) = client
.configure_provider_refresh(ConfigureProviderRefreshRequest {
request_id: String::new(),
provider: provider_name.clone(),
credential_key: adc_credential_key.clone(),
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
Expand All @@ -1178,6 +1185,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->

if let Err(rotate_err) = client
.rotate_provider_credential(RotateProviderCredentialRequest {
request_id: String::new(),
provider: provider_name.clone(),
credential_key: adc_credential_key,
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -1601,6 +1609,7 @@ pub async fn provider_profile_import(
if !items.is_empty() {
let response = client
.import_provider_profiles(ImportProviderProfilesRequest {
request_id: String::new(),
profiles: items,
workspace: workspace.to_string(),
})
Expand Down Expand Up @@ -1650,6 +1659,7 @@ pub async fn provider_profile_update(
.map_or(0, |profile| profile.resource_version);
let response = client
.update_provider_profiles(UpdateProviderProfilesRequest {
request_id: String::new(),
profile: Some(item),
expected_resource_version,
id: id.to_string(),
Expand Down Expand Up @@ -1714,6 +1724,7 @@ pub async fn provider_profile_delete(
for id in ids {
let response = match client
.delete_provider_profile(DeleteProviderProfileRequest {
request_id: String::new(),
allow_missing: true,
id: id.clone(),
workspace: workspace.to_string(),
Expand Down Expand Up @@ -1826,6 +1837,7 @@ pub async fn provider_refresh_config(
let mut client = grpc_client(server, tls).await?;
let status = client
.configure_provider_refresh(ConfigureProviderRefreshRequest {
request_id: String::new(),
provider: input.name.to_string(),
credential_key: input.credential_key.to_string(),
strategy: strategy as i32,
Expand Down Expand Up @@ -1863,6 +1875,7 @@ pub async fn provider_rotate(
let mut client = grpc_client(server, tls).await?;
let status = client
.rotate_provider_credential(RotateProviderCredentialRequest {
request_id: String::new(),
provider: name.to_string(),
credential_key: credential_key.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -1900,6 +1913,7 @@ pub async fn provider_refresh_delete(
let mut client = grpc_client(server, tls).await?;
let response = client
.delete_provider_refresh(DeleteProviderRefreshRequest {
request_id: String::new(),
allow_missing: true,
provider: name.to_string(),
credential_key: credential_key.to_string(),
Expand Down Expand Up @@ -2337,6 +2351,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> {

let response = client
.update_provider(UpdateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -2399,6 +2414,7 @@ pub async fn provider_delete(
for name in names {
let response = match client
.delete_provider(DeleteProviderRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down
16 changes: 16 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ pub async fn sandbox_create(
)])
};
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
resource_requirements,
environment: if template.is_none() {
Expand Down Expand Up @@ -2685,6 +2686,7 @@ pub async fn sandbox_template_create(
let mut client = grpc_client(server, tls).await?;
let response = client
.create_sandbox_template(CreateSandboxTemplateRequest {
request_id: String::new(),
template: Some(SandboxWorkloadTemplate {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
Expand Down Expand Up @@ -2872,6 +2874,7 @@ pub async fn sandbox_template_delete(
for name in names {
let response = client
.delete_sandbox_template(DeleteSandboxTemplateRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -3300,6 +3303,7 @@ pub async fn sandbox_delete(

let response = match client
.delete_sandbox(DeleteSandboxRequest {
request_id: String::new(),
allow_missing: true,
name: name.clone(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -3360,6 +3364,7 @@ pub async fn sandbox_stop(
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.stop_sandbox(StopSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
Expand All @@ -3383,6 +3388,7 @@ pub async fn sandbox_start(
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.start_sandbox(StartSandboxRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
Expand Down Expand Up @@ -3481,6 +3487,7 @@ pub async fn service_expose(
let mut client = grpc_client(server, tls).await?;
let response = client
.expose_service(ExposeServiceRequest {
request_id: String::new(),
sandbox: sandbox.to_string(),
service: service.to_string(),
target_port: u32::from(target_port),
Expand Down Expand Up @@ -3607,6 +3614,7 @@ pub async fn service_delete(
let mut client = grpc_client(server, tls).await?;
let response = client
.delete_service(DeleteServiceRequest {
request_id: String::new(),
allow_missing: false,
sandbox: sandbox.to_string(),
service: service.to_string(),
Expand Down Expand Up @@ -3827,6 +3835,7 @@ pub async fn workspace_create(
let mut client = grpc_client(server, tls).await?;
let response = client
.create_workspace(CreateWorkspaceRequest {
request_id: String::new(),
name: name.to_string(),
labels,
})
Expand Down Expand Up @@ -3981,6 +3990,7 @@ pub async fn workspace_delete(server: &str, names: &[String], tls: &TlsOptions)
for name in names {
let response = client
.delete_workspace(DeleteWorkspaceRequest {
request_id: String::new(),
allow_missing: false,
name: name.clone(),
})
Expand Down Expand Up @@ -4018,6 +4028,7 @@ pub async fn workspace_member_add(
let mut client = grpc_client(server, tls).await?;
let response = client
.add_workspace_member(AddWorkspaceMemberRequest {
request_id: String::new(),
workspace: workspace.to_string(),
principal_subject: subject.to_string(),
role: role_val.into(),
Expand Down Expand Up @@ -4052,6 +4063,7 @@ pub async fn workspace_member_remove(
let mut client = grpc_client(server, tls).await?;
let response = client
.remove_workspace_member(RemoveWorkspaceMemberRequest {
request_id: String::new(),
allow_missing: true,
workspace: workspace.to_string(),
principal_subject: subject.to_string(),
Expand Down Expand Up @@ -5876,6 +5888,7 @@ pub async fn sandbox_draft_approve(

let response = client
.approve_draft_chunk(ApproveDraftChunkRequest {
request_id: String::new(),
name: name.to_string(),
chunk_id: chunk_id.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -5908,6 +5921,7 @@ pub async fn sandbox_draft_reject(

client
.reject_draft_chunk(RejectDraftChunkRequest {
request_id: String::new(),
name: name.to_string(),
chunk_id: chunk_id.to_string(),
reason: reason.to_string(),
Expand Down Expand Up @@ -5949,6 +5963,7 @@ pub async fn sandbox_draft_approve_all(

let response = client
.approve_all_draft_chunks(ApproveAllDraftChunksRequest {
request_id: String::new(),
name: name.to_string(),
include_security_flagged,
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
Expand Down Expand Up @@ -5980,6 +5995,7 @@ pub async fn sandbox_draft_clear(

let response = client
.clear_draft_chunks(ClearDraftChunksRequest {
request_id: String::new(),
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-gateway-interceptors/src/proto_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ mod tests {
let codec =
ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
providers: vec!["github".to_string()],
..SandboxSpec::default()
Expand Down Expand Up @@ -336,6 +337,7 @@ mod tests {
fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() {
let codec = ProtoJsonCodec::openshell().unwrap();
let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
r#type: "github".to_string(),
credentials: HashMap::from([(
Expand Down Expand Up @@ -396,6 +398,7 @@ mod tests {
fn generic_sandbox_environment_remains_visible() {
let codec = ProtoJsonCodec::openshell().unwrap();
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]),
..SandboxSpec::default()
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-gateway-interceptors/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ mod tests {

fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation {
let request = CreateProviderRequest {
request_id: String::new(),
provider: Some(Provider {
r#type: "github".to_string(),
credentials: HashMap::from([(
Expand Down Expand Up @@ -1009,6 +1010,7 @@ mod tests {
codec: codec.clone(),
};
let request = UpdateConfigRequest {
request_id: String::new(),
name: "demo".to_string(),
expected_resource_version: u64::MAX - 1,
annotations: HashMap::from([
Expand Down Expand Up @@ -1049,6 +1051,7 @@ mod tests {
let codec =
ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
let request = CreateSandboxRequest {
request_id: String::new(),
spec: Some(SandboxSpec {
template: Some(SandboxTemplate {
resources: Some(
Expand Down
Loading
Loading