Merge commit '6f5b165831a7f4ce63758c0f63027e56c5028cd8' into work/t838-source-policy-adoption

This commit is contained in:
2026-11-04 16:20:34 +09:00
5 changed files with 374 additions and 4 deletions
+42
View File
@@ -3,6 +3,48 @@
//! Causes contain only fixed enum values. Stored or transferred attachments are
//! observations, never permission to disclose: Browser/Tool projections require
//! the caller's current visibility decision and fail closed without it.
//!
//! # Projection boundary
//!
//! The same closed cause is mapped explicitly into the shared error-contract codec.
//! The transport boundary does not convert an internal Session denial into an HTTP
//! verdict about a Browser operator. Public consumers must resolve current viewer
//! authority before projecting; a saved attachment cannot carry that authority.
//!
//! | Contract fact | Internal transfer / storage | Authorized Browser / Tool |
//! | --- | --- | --- |
//! | Domain, reason, principal, required operation | Typed cause | Same cause, never inferred from prose |
//! | Explanation | Fixed local template | Fixed local template, not received text |
//! | Operation context | Allowlisted operation/stage pairs | Same allowlist, no operation references |
//! | Subject, Session, Worker IDs; credentials; paths; raw HTTP body | Not representable in this cause | Not representable in this cause |
//! | Resource ownership, persistence, secondary cleanup | Separate startup result facts | Not disclosure/retry authority |
//!
//! A denied current visibility decision fails projection instead of substituting
//! an invented cause. Unknown codec versions and corrupt attachments stay distinct
//! from known causes. Missing legacy attachments do not certify legacy diagnostics.
//!
//! # Failure investigation
//!
//! A known denial identifies the operation and principal class, not an instruction
//! to grant rights. Check the current Subject execution policy and the authenticated
//! Runtime/Worker binding at the Session-record authorization boundary. Compare
//! existing Session attribution and saved Worker metadata before deciding whether
//! any state repair is needed. Operator authority and Subject execution policy are
//! different decisions; neither can be derived from an internal HTTP status alone.
//!
//! Transport and unknown outcomes do not establish non-execution. Reconcile the
//! existing Session attribution, Worker aggregate and Runtime operation result;
//! a lost response may coexist with a committed write. A conflict invalidates the
//! old metadata expectation. Do not retry the startup, create another Worker, or
//! enlarge permissions solely because a failure was displayed. Inspect primary,
//! secondary, persistence and resource facts independently: rollback failure must
//! not replace the refusal, and persistence uncertainty must not be called saved.
//!
//! The real authorization/factory fixture is owned by workspace-server's normal
//! `session_registration_connected` tests. Its signed Session callback revokes the
//! real policy at a synchronization point; a real retention pin forces independent
//! cleanup failure. Leaf transport/projection tests do not substitute for that
//! product connection, and the fixture claims neither a process crash nor restore.
use error_contract::{
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
+4
View File
@@ -897,6 +897,10 @@ pub(crate) fn test_subject_authorization_response(
})
}
#[cfg(test)]
#[path = "subjektiv_session_registration_tests.rs"]
mod session_registration_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,159 @@
//! The real authorization/factory fixture lives in workspace-server. This leaf
//! exercises only WorkspaceClient transfer failures and response validation; it
//! must not substitute simulated policy decisions for that connected fixture.
use super::*;
use crate::{WorkspaceClientError, WorkspaceResponse};
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause as Cause};
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug)]
struct Client {
reply: Reply,
calls: AtomicUsize,
}
#[derive(Debug)]
enum Reply {
Response(u16, String),
Unavailable,
Request,
}
impl WorkspaceClient for Client {
fn kind(&self) -> &str {
"registration-transfer-test"
}
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.calls.fetch_add(1, Ordering::SeqCst);
assert_eq!(request.method, WorkspaceRequestMethod::Post);
assert_eq!(request.path, "/api/w/workspace/subjektiv/sessions");
match &self.reply {
Reply::Response(status, body) => Ok(WorkspaceResponse {
status: *status,
body: body.clone(),
}),
Reply::Unavailable => Err(WorkspaceClientError::Unavailable(
"TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
)),
Reply::Request => Err(WorkspaceClientError::Request(
"TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
)),
}
}
}
fn client(reply: Reply) -> Client {
Client {
reply,
calls: AtomicUsize::new(0),
}
}
fn failure_body(cause: Cause) -> String {
let mut error = RepositoryApiError::new(
403,
"ignored",
"TEST_ONLY_CREDENTIAL /private/path raw_http_body",
vec![],
);
error.failure =
Some(protocol::session_failure::session_registration_attachment(cause).unwrap());
serde_json::to_string(&error).unwrap()
}
fn assert_failure(client: Client, expected: Cause) -> SubjektivHostError {
let error = record_backend_session(&client, "session", true).unwrap_err();
assert_eq!(
client.calls.load(Ordering::SeqCst),
1,
"classification grants no replay"
);
assert_eq!(error.session_registration_cause(), expected);
let attachment = protocol::session_failure::session_registration_attachment(
error.session_registration_cause(),
)
.unwrap();
let encoded = serde_json::to_string(&attachment).unwrap();
for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] {
assert!(!encoded.contains(secret));
assert!(!error.to_string().contains(secret));
}
error
}
#[test]
fn session_registration_http_reuses_typed_causes_not_status_or_prose() {
for cause in [
Cause::denied(SessionPrincipal::Operator),
Cause::denied(SessionPrincipal::Subject),
Cause::attribution_missing(),
Cause::binding_mismatch(),
Cause::outcome_unknown(),
] {
for status in [403, 409, 500] {
assert_failure(client(Reply::Response(status, failure_body(cause))), cause);
}
}
// A legacy 403 without a domain attestation is not a certified denial.
let body = serde_json::to_string(&RepositoryApiError::new(
403,
"permission_denied",
"session.record denied",
vec![],
))
.unwrap();
assert_failure(client(Reply::Response(403, body)), Cause::outcome_unknown());
}
#[test]
fn session_registration_transport_and_unknown_keep_error_sources_without_disclosure_or_retry() {
for (reply, cause) in [
(Reply::Unavailable, Cause::transport()),
(Reply::Request, Cause::outcome_unknown()),
] {
let error = assert_failure(client(reply), cause);
assert!(
std::error::Error::source(&error)
.unwrap()
.downcast_ref::<WorkspaceClientError>()
.is_some()
);
}
let legacy =
SubjektivHostError::PermissionDenied("session.record denied for secret subject".into());
assert_eq!(
legacy.session_registration_cause(),
Cause::outcome_unknown()
);
}
#[test]
fn session_registration_rejects_malformed_incomplete_and_mismatched_responses() {
for (status, body) in [
(403, "not json"),
(200, "not json"),
(200, "{}"),
(200, r#"{"session_id":"session","subject_id":""}"#),
(
200,
r#"{"session_id":"other-session","subject_id":"subject"}"#,
),
] {
assert_failure(
client(Reply::Response(status, body.into())),
Cause::invalid_response(),
);
}
let allowed = client(Reply::Response(
200,
r#"{"session_id":"session","subject_id":"subject"}"#.into(),
));
let output = record_backend_session(&allowed, "session", true).unwrap();
assert_eq!(output.session_id, "session");
assert_eq!(output.subject_id, "subject");
assert_eq!(allowed.calls.load(Ordering::SeqCst), 1);
}
+76
View File
@@ -6772,6 +6772,9 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
),
};
if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error {
if result.startup_failure.is_some() {
projection.message = "Embedded Runtime Worker startup failed".into();
}
projection.startup = result.startup_failure.as_ref().and_then(|primary| {
internal_startup_transfer(runtime_api::WorkerStartupFailure {
primary: primary.clone(),
@@ -7178,6 +7181,79 @@ mod tests {
use std::sync::{Arc, Mutex};
use std::thread;
#[test]
fn session_registration_embedded_and_remote_host_projection_is_internal_only() {
use protocol::session_failure::{
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
};
use worker_runtime::execution::{
WorkerExecutionOperation, WorkerExecutionOutcome, WorkerExecutionResult,
};
let mut result = WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn);
result.outcome = WorkerExecutionOutcome::Errored;
result.startup_failure = Some(
session_registration_attachment(SessionRegistrationCause::denied(
SessionPrincipal::Subject,
))
.unwrap(),
);
result.startup_failure_persistence =
Some(protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown);
result.secondary_failures.push(
protocol::startup_failure::StartupSecondaryFailure::Persistence {
phase: protocol::startup_failure::StartupFailurePhase::MetadataSave,
outcome: protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown,
},
);
let error = EmbeddedRuntimeError::WorkerExecutionRejected {
worker_id: EmbeddedWorkerId::now_v7(),
operation: result.operation,
outcome: result.outcome,
message: "TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
result,
};
let embedded = embedded_runtime_diagnostic(&error);
let mut wire = runtime_api::RuntimeApiError::new(
500,
"worker_execution_rejected",
"unused remote prose",
);
wire.error.startup = embedded.startup.clone();
let received = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
let remote = runtime_api_diagnostic(
"remote",
runtime_api::client_support::ClientError::Public {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error: received,
},
);
assert!(embedded.startup.is_some());
assert_eq!(embedded.startup, remote.startup);
let public = serde_json::to_value(&remote).unwrap();
assert!(
public.get("startup").is_none(),
"no blind public/storage attachment forwarding"
);
for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] {
assert!(!serde_json::to_string(&embedded).unwrap().contains(secret));
}
let legacy = runtime_api_diagnostic(
"remote",
runtime_api::client_support::ClientError::Public {
status: reqwest::StatusCode::FORBIDDEN,
error: runtime_api::RuntimeApiError::new(403, "forbidden", "session.record denied"),
},
);
assert!(
legacy.startup.is_none(),
"HTTP status and prose cannot attest a Session cause"
);
let mut corrupt = embedded.startup.unwrap();
corrupt.primary = serde_json::from_value(json!("not an error-contract envelope")).unwrap();
assert!(internal_startup_transfer(corrupt).is_none());
}
#[tokio::test]
async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() {
use crate::db_executor::{DbExecutor, DbExecutorLimits};
@@ -199,10 +199,99 @@ fn contains_saved_value(saved: &Value, expected: &Value) -> bool {
}
fn read_metadata(root: &std::path::Path, worker: &str) -> Value {
// WorkerAggregateStore owns metadata.json; worker.json is the Runtime
// catalog. Open a new file handle on each read.
let path = root.join("workers").join(worker).join("metadata.json");
serde_json::from_reader(std::fs::File::open(path).unwrap()).unwrap()
// Reopen the canonical metadata store, not the separate Runtime catalog.
let name = format!("worker-runtime-{worker}");
let store = session_store::WorkerAggregateStore::open_read_only(
&root.join("workers").join(worker),
&name,
)
.unwrap();
let metadata = store.read_by_name(&name).unwrap().unwrap();
assert!(matches!(
metadata.decode_startup_failure::<SessionRegistrationCause>(),
session_store::worker_metadata::StartupFailureRead::Present(_)
));
serde_json::to_value(metadata).unwrap()
}
#[tokio::test]
async fn session_registration_real_judgments_separate_operator_subject_and_attribution() {
// This normal boundary fixture uses the real router, Runtime signature,
// authority store and attribution store; its Runtime does not run a model.
let f = Fixture::new().await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
let missing_session = session_store::new_session_id().to_string();
let request = json!({"session_id": missing_session, "create_if_missing": false});
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &path, Some(request.clone())),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_registration_cause(
body,
SessionRegistrationCause::denied(SessionPrincipal::Operator),
);
let signed = |request: &Value| {
runtime_source_request(
&f.identity,
Some(&f.source.worker_id),
"POST",
&path,
serde_json::to_vec(request).unwrap(),
)
};
let (status, body) = response(&f.api, signed(&request)).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_registration_cause(body, SessionRegistrationCause::attribution_missing());
let store = open_subjektiv_store(&f.api).unwrap();
let foreign_session = session_store::new_session_id().to_string();
store
.record_session_attribution(
crate::subjektiv::SubjectSessionAttribution::new(
&f.subject,
&f.source.runtime_id,
"foreign-worker",
&foreign_session,
)
.unwrap(),
)
.unwrap();
let (status, body) = response(
&f.api,
signed(&json!({"session_id":foreign_session, "create_if_missing":false})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_registration_cause(body, SessionRegistrationCause::binding_mismatch());
// The fixture removes its bootstrap policy only after the real setup POST.
let (status, body) = response(
&f.api,
signed(&json!({"session_id":missing_session, "create_if_missing":true})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_registration_cause(
body,
SessionRegistrationCause::denied(SessionPrincipal::Subject),
);
assert!(
store
.session_attribution(&missing_session)
.unwrap()
.is_none()
);
}
fn assert_registration_cause(body: Value, cause: SessionRegistrationCause) {
let error: server_api::RepositoryApiError = serde_json::from_value(body).unwrap();
assert_eq!(
error.failure,
Some(session_registration_attachment(cause).unwrap())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]