test: cover authorization judgments transfer privacy and failure investigation
This commit is contained in:
@@ -3,6 +3,48 @@
|
|||||||
//! Causes contain only fixed enum values. Stored or transferred attachments are
|
//! Causes contain only fixed enum values. Stored or transferred attachments are
|
||||||
//! observations, never permission to disclose: Browser/Tool projections require
|
//! observations, never permission to disclose: Browser/Tool projections require
|
||||||
//! the caller's current visibility decision and fail closed without it.
|
//! 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::{
|
use error_contract::{
|
||||||
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
|
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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);
|
||||||
|
}
|
||||||
@@ -6772,6 +6772,9 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error {
|
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| {
|
projection.startup = result.startup_failure.as_ref().and_then(|primary| {
|
||||||
internal_startup_transfer(runtime_api::WorkerStartupFailure {
|
internal_startup_transfer(runtime_api::WorkerStartupFailure {
|
||||||
primary: primary.clone(),
|
primary: primary.clone(),
|
||||||
@@ -7178,6 +7181,79 @@ mod tests {
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
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]
|
#[tokio::test]
|
||||||
async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() {
|
async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() {
|
||||||
use crate::db_executor::{DbExecutor, DbExecutorLimits};
|
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 {
|
fn read_metadata(root: &std::path::Path, worker: &str) -> Value {
|
||||||
// WorkerAggregateStore owns metadata.json; worker.json is the Runtime
|
// Reopen the canonical metadata store, not the separate Runtime catalog.
|
||||||
// catalog. Open a new file handle on each read.
|
let name = format!("worker-runtime-{worker}");
|
||||||
let path = root.join("workers").join(worker).join("metadata.json");
|
let store = session_store::WorkerAggregateStore::open_read_only(
|
||||||
serde_json::from_reader(std::fs::File::open(path).unwrap()).unwrap()
|
&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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
|||||||
Reference in New Issue
Block a user