From 7f03db9d7959730ce4d329a18edb9dc0c9a8df8f Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 03:36:27 +0900 Subject: [PATCH] Preserve verified case judgment identity and canonical route authentication --- crates/workspace-server/src/server.rs | 121 ++- .../src/server/tests/ticket_evidence_tests.rs | 25 +- .../src/server_ticket_case_signed_tests.rs | 758 ++++++++++++++++++ docs/development/ticket-case-state.md | 2 +- 4 files changed, 894 insertions(+), 12 deletions(-) create mode 100644 crates/workspace-server/src/server_ticket_case_signed_tests.rs diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 13b336831..d391d9f33 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2804,7 +2804,9 @@ async fn authorize_workspace_api_request( mut request: Request, next: axum::middleware::Next, ) -> Response { - if !request.uri().path().starts_with("/api/") { + if !request.uri().path().starts_with("/api/") + && ticket_case_workspace_id(request.uri().path()).is_none() + { return next.run(request).await; } let request_path = request.uri().path().to_owned(); @@ -3024,7 +3026,9 @@ async fn dispatch_workspace_request( None }; if let Some(workspace_id) = workspace_id - && (path.starts_with("/api/w/") || path.starts_with("/api/runtime/v1/workspaces/")) + && (path.starts_with("/api/w/") + || path.starts_with("/api/runtime/v1/workspaces/") + || ticket_case_workspace_id(&path).is_some()) && let Err(response) = authorize_scoped_workspace_request(&api, workspace_id, &mut request).await { @@ -3098,7 +3102,26 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } +// The canonical case route is deliberately not an /api/w alias. Recognize only +// this operation so its authentication/scope is identical without broadening CRUD. +fn ticket_case_workspace_id(path: &str) -> Option<&str> { + let mut segments = path.strip_prefix("/workspaces/")?.split('/'); + let workspace_id = segments.next()?; + let resource = segments.next()?; + let ticket_id = segments.next()?; + let operation = segments.next()?; + (!workspace_id.is_empty() + && resource == "tickets" + && !ticket_id.is_empty() + && operation == "case" + && segments.next().is_none()) + .then_some(workspace_id) +} + fn scoped_workspace_id(path: &str) -> Option<&str> { + if let Some(workspace_id) = ticket_case_workspace_id(path) { + return Some(workspace_id); + } if let Some(rest) = path.strip_prefix("/api/runtime/v1/workspaces/") { return rest .split('/') @@ -10279,9 +10302,10 @@ impl server_api::ServerApi for ServerApiContractService { ticket_id: String, request: server_api::TicketCaseUpdateRequest, ) -> std::result::Result { - let Json(updated) = scoped_ticket_case_update( + let Json(updated) = scoped_ticket_case_update_with_context( State(self.workspace_api()?.clone()), contract_request_headers(&context)?, + &context, AxumPath((workspace_id, ticket_id)), Json(ticket::TicketCaseUpdate { expected_content_digest: request.expected_content_digest, @@ -14450,9 +14474,15 @@ async fn execute_ticket_rest_operation_with_context( ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; if let TicketBackendOperation::UpdateCase { ticket, request } = operation { - let Json(updated) = scoped_ticket_case_update( + let context = context.ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Ticket case decisions require verified request context".into(), + ) + })?; + let Json(updated) = scoped_ticket_case_update_with_context( State(api.clone()), headers, + context, AxumPath((workspace_id.to_string(), ticket)), Json(request), ) @@ -16466,9 +16496,74 @@ async fn scoped_complete_merge_request( .map_err(Into::into) } +// Header-only entry point is retained solely for explicit legacy internal tests. +// Public dispatch must use the middleware-populated context below. +#[cfg(test)] async fn scoped_ticket_case_update( State(api): State, headers: HeaderMap, + path: AxumPath<(String, String)>, + request: Json, +) -> ApiResult> { + let source = optional_worker_mutation_source(&api, &path.0.0, &headers)?; + apply_ticket_case_update(State(api), source, None, path, request).await +} + +async fn scoped_ticket_case_update_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String)>, + request: Json, +) -> ApiResult> { + // Authentication already verified membership and the request's signature/body. + // Hints are redundant assertions, never authority or a required identity source. + let source = if let Some(source) = context.runtime_source.as_ref() { + let worker_id = source.worker_id.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Ticket case decisions require a verified Runtime Worker identity".into(), + ) + })?; + Some(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) + } else { + context + .worker_source + .as_ref() + .map(|source| RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id)) + }; + if headers.contains_key("x-yoi-runtime-id") || headers.contains_key("x-yoi-worker-id") { + let matching = source.as_ref().is_some_and(|source| { + [ + ("x-yoi-runtime-id", &source.runtime_id), + ("x-yoi-worker-id", &source.worker_id), + ] + .into_iter() + .all(|(name, expected)| { + headers + .get(name) + .is_some_and(|value| value.as_bytes() == expected.as_bytes()) + }) + }); + if !matching { + return Err(Error::WorkspacePermissionDenied( + "Ticket case source hints do not match the verified Runtime Worker".into(), + ) + .into()); + } + } + if source.is_none() && context.actor.is_none() { + return Err(Error::WorkspacePermissionDenied( + "Ticket case decisions require an authenticated actor".into(), + ) + .into()); + } + apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await +} + +async fn apply_ticket_case_update( + State(api): State, + source: Option, + actor: Option<&RequestActor>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(mut request): Json, ) -> ApiResult> { @@ -16478,7 +16573,7 @@ async fn scoped_ticket_case_update( let before = backend .show(TicketIdOrSlug::Id(ticket_id.clone())) .map_err(Error::from)?; - if let Some(source) = optional_worker_mutation_source(&api, &workspace_id, &headers)? { + if let Some(source) = source { let author = format!("worker:{}/{}", source.runtime_id, source.worker_id); let replay_event = before.events.iter().find(|event| { event.author.as_deref() == Some(author.as_str()) @@ -16509,7 +16604,19 @@ async fn scoped_ticket_case_update( context.attributes("case_update") }; backend = backend.with_event_attributes(attributes); + } else if let Some(actor) = actor { + // Include both verified IDs in the author (and thus the receipt fingerprint). + // JSON tuple encoding avoids ambiguous identity concatenation; proof tokens, + // auth method and signature timestamps are not replay identity. + let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) + .map_err(|error| Error::Config(format!("failed to encode case actor: {error}")))?; + request.author = Some(format!("workspace-user:{identity}")); + backend = backend.with_event_attributes(BTreeMap::from([ + ("source_account_id".into(), actor.account_id.clone()), + ("source_user_id".into(), actor.user_id.clone()), + ])); } else { + // Only the test-only header adapter can reach this legacy attribution. request.author = Some("workspace-user".into()); } let ticket = backend @@ -32381,6 +32488,10 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + mod ticket_case_signed_tests { + include!("server_ticket_case_signed_tests.rs"); + } + mod ticket_tags_tests { include!("server_ticket_tags_tests.rs"); } diff --git a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs index 6a5e473bc..f50cc7e95 100644 --- a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -739,8 +739,13 @@ async fn mrless_completed_closure_is_a_ticket_judgment_not_approved_or_missing_m } } -// Exercise the generated public API through the router, then reread the real -// Ticket authority. No live Runtime, repository provider, or Worker process runs. +// Exercise the generated handler's JSON/CAS boundary, then reread the real +// Ticket authority. Supply the actor normally established by middleware; signed +// case-route tests cover real authentication. No live Runtime/provider runs. +fn case_actor_router(api: WorkspaceApi) -> Router { + build_inner_router(api).layer(Extension(test_browser_request_actor())) +} + fn case_request(before: &ticket::Ticket, operation_key: &str, action: Value) -> Value { json!({ "operation_key": operation_key, @@ -762,7 +767,7 @@ async fn case_route_rejects_missing_cas_client_author_and_removed_workflow_route .create(ticket::NewTicket::new("Transport decisions")) .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); - let app = build_inner_router(api); + let app = case_actor_router(api); let path = format!( "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", reference.id @@ -863,7 +868,7 @@ async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_with .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); let digest = ticket::ticket_content_digest(&before); - let app = build_inner_router(api); + let app = case_actor_router(api); let path = format!( "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", reference.id @@ -885,10 +890,18 @@ async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_with let summary: server_api::TicketSummary = serde_json::from_value(response).unwrap(); assert_eq!(summary.0.workflow_state, ticket::TicketState::Closed); let closed = backend.show(reference.id.clone().into()).unwrap(); + let actor = test_browser_request_actor(); + let expected_author = format!( + "workspace-user:{}", + serde_json::to_string(&(&actor.account_id, &actor.user_id)).unwrap() + ); assert_eq!( closed.current_closure.as_ref().unwrap().author, - "workspace-user" + expected_author ); + let event = closed.events.last().unwrap(); + assert_eq!(event.attributes["source_account_id"], actor.account_id); + assert_eq!(event.attributes["source_user_id"], actor.user_id); assert_eq!( closed.current_closure.as_ref().unwrap().reason, ticket::TicketClosureReason::Completed @@ -1031,7 +1044,7 @@ async fn case_route_rejects_stale_content_state_and_invalid_actions_without_audi .create(ticket::NewTicket::new("CAS decision")) .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); - let app = build_inner_router(api); + let app = case_actor_router(api); let path = format!( "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", reference.id diff --git a/crates/workspace-server/src/server_ticket_case_signed_tests.rs b/crates/workspace-server/src/server_ticket_case_signed_tests.rs new file mode 100644 index 000000000..78ba317b2 --- /dev/null +++ b/crates/workspace-server/src/server_ticket_case_signed_tests.rs @@ -0,0 +1,758 @@ +use super::*; + +async fn case_response( + app: Router, + request: Request, + expected: StatusCode, +) -> serde_json::Value { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap() + } +} + +fn signed_case_request( + identity: &RuntimeIdentityMaterial, + worker: Option<&str>, + ticket: &str, + input: &ticket::TicketCaseUpdate, +) -> Request { + let mut body = serde_json::to_value(input).unwrap(); + body.as_object_mut().unwrap().remove("author"); + runtime_source_request( + identity, + worker, + "POST", + &format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/case"), + serde_json::to_vec(&body).unwrap(), + ) +} + +#[tokio::test] +async fn signed_unassigned_worker_without_hints_cannot_close_reopen_or_correct_cases() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + seed_worker_source_member(&api, "runtime-case", "other"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Case authority")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "unauthorized-owner", + ); + let app = build_router(api.clone()); + for action in [ + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ticket::TicketCaseAction::Reopen, + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ] { + if !matches!(action, ticket::TicketCaseAction::Close { .. }) { + let before = backend.show(reference.id.clone().into()).unwrap(); + if before.meta.workflow_state == TicketState::Open { + backend + .update_case( + &reference.id, + case_test_request( + &before, + "fixture-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ), + ) + .unwrap(); + } + } + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request(&before, "unauthorized", action); + let authority = case_authority_snapshot(&api); + case_response( + app.clone(), + signed_case_request(&identity, Some("other"), &reference.id, &input), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); + } + assert!(execution.protocol_methods().is_empty()); +} + +#[tokio::test] +async fn signed_runtime_without_worker_cannot_make_case_judgments() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Runtime is not a case actor")) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "runtime-only", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + case_response( + build_router(api.clone()), + signed_case_request(&identity, None, &reference.id, &input), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), before); +} + +fn seed_case_assignment(api: &WorkspaceApi, ticket: &str, worker: &RuntimeWorkerRef, key: &str) { + seed_worker_source_member(api, &worker.runtime_id, &worker.worker_id); + api.store + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: ticket.into(), + assignment_id: key.into(), + worker: worker.clone(), + assigned_by: "test".into(), + assigned_at: TEST_CREATED_AT.into(), + }, + None, + &format!("event-{key}"), + &format!("operation-{key}"), + false, + ) + .unwrap(); +} + +fn case_authority_snapshot(api: &WorkspaceApi) -> Vec>> { + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + [ + "worker_registry", + "worker_control_grants", + "ticket_current_worker_assignments", + "ticket_assignment_operations", + "ticket_assignment_work_releases", + "backend_jobs", + ] + .into_iter() + .map(|table| { + let mut statement = conn + .prepare(&format!( + "SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid" + )) + .unwrap(); + let columns = statement.column_count(); + statement + .query_map([TEST_WORKSPACE_ID], |row| { + (0..columns) + .map(|column| row.get(column)) + .collect::>>() + }) + .unwrap() + .collect::>>() + .unwrap() + }) + .collect() +} + +#[tokio::test] +async fn signed_assigned_worker_case_replay_keeps_original_actor_after_release_without_restoring_grant() + { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let source = RuntimeWorkerRef::new("runtime-case", "owner"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Source-bound case receipt")) + .unwrap(); + seed_case_assignment(&api, &reference.id, &source, "original-work"); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "signed-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + let first = signed_case_request(&identity, Some("owner"), &reference.id, &input); + let old_proof = + first.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone(); + let response = case_response(app.clone(), first, StatusCode::OK).await; + assert_eq!(response["workflow_state"], "closed"); + let closed = backend.show(reference.id.clone().into()).unwrap(); + let event = closed.events.last().unwrap(); + assert_eq!( + closed.case_history.last().unwrap().author, + "worker:runtime-case/owner" + ); + assert_eq!(event.author.as_deref(), Some("worker:runtime-case/owner")); + for (key, expected) in [ + ("source_runtime_id", "runtime-case"), + ("source_worker_id", "owner"), + ("source_actor_role", "ticket_worker"), + ("source_assignment_id", "original-work"), + ] { + assert_eq!( + event.attributes.get(key).map(String::as_str), + Some(expected) + ); + } + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .is_none() + ); + assert_eq!( + api.store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .unwrap() + .worker, + source + ); + let released = case_authority_snapshot(&api); + // Proof tokens are single-use; operation receipts survive fresh signatures. + let mut reused_proof = signed_case_request(&identity, Some("owner"), &reference.id, &input); + reused_proof.headers_mut().insert( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + old_proof, + ); + case_response(app.clone(), reused_proof, StatusCode::UNAUTHORIZED).await; + assert_eq!( + case_response( + app.clone(), + signed_case_request(&identity, Some("owner"), &reference.id, &input), + StatusCode::OK + ) + .await, + response + ); + let mut changed = input.clone(); + changed.explanation = "Changed judgment under the same key".into(); + case_response( + app.clone(), + signed_case_request(&identity, Some("owner"), &reference.id, &changed), + StatusCode::CONFLICT, + ) + .await; + let reopen = case_test_request( + &closed, + "released-worker-reopen", + ticket::TicketCaseAction::Reopen, + ); + case_response( + app.clone(), + signed_case_request(&identity, Some("owner"), &reference.id, &reopen), + StatusCode::FORBIDDEN, + ) + .await; + let correction = case_test_request( + &closed, + "released-worker-correction", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + case_response( + app, + signed_case_request(&identity, Some("owner"), &reference.id, &correction), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), closed); + assert_eq!(case_authority_snapshot(&api), released); + assert!(execution.protocol_methods().is_empty()); +} + +#[tokio::test] +async fn signed_case_hints_cannot_replace_verified_worker_or_human_identity() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Hints are assertions")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "hint-owner", + ); + seed_worker_source_member(&api, "runtime-case", "other"); + let before = backend.show(reference.id.clone().into()).unwrap(); + let authority = case_authority_snapshot(&api); + let input = case_test_request( + &before, + "hint-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + for (worker, runtime_hint, worker_hint) in [ + (Some("other"), Some("runtime-case"), Some("owner")), + (Some("owner"), Some("another-runtime"), Some("owner")), + (Some("owner"), Some("runtime-case"), None), + (None, Some("runtime-case"), Some("owner")), + ] { + let mut request = signed_case_request(&identity, worker, &reference.id, &input); + if let Some(runtime) = runtime_hint { + request + .headers_mut() + .insert("x-yoi-runtime-id", HeaderValue::from_str(runtime).unwrap()); + } + if let Some(worker) = worker_hint { + request + .headers_mut() + .insert("x-yoi-worker-id", HeaderValue::from_str(worker).unwrap()); + } + case_response(app.clone(), request, StatusCode::FORBIDDEN).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); + } + let token = seed_test_api_token(api.store.as_ref(), "hint-human"); + let mut human = signed_case_request(&identity, Some("owner"), &reference.id, &input); + human + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + human.headers_mut().insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + human + .headers_mut() + .insert("x-yoi-runtime-id", HeaderValue::from_static("runtime-case")); + human + .headers_mut() + .insert("x-yoi-worker-id", HeaderValue::from_static("owner")); + case_response(app.clone(), human, StatusCode::FORBIDDEN).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); + let mut matching = signed_case_request(&identity, Some("owner"), &reference.id, &input); + matching + .headers_mut() + .insert("x-yoi-runtime-id", HeaderValue::from_static("runtime-case")); + matching + .headers_mut() + .insert("x-yoi-worker-id", HeaderValue::from_static("owner")); + case_response(app, matching, StatusCode::OK).await; +} + +#[tokio::test] +async fn case_receipt_cannot_be_replayed_by_different_authenticated_worker() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Distinct Worker receipt identity")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "first"), + "first-work", + ); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "identity-key", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + case_response( + app.clone(), + signed_case_request(&identity, Some("first"), &reference.id, &input), + StatusCode::OK, + ) + .await; + let closed = backend.show(reference.id.clone().into()).unwrap(); + backend + .update_case( + &reference.id, + case_test_request(&closed, "fixture-reopen", ticket::TicketCaseAction::Reopen), + ) + .unwrap(); + api.store + .clear_current_ticket_worker_assignment( + TEST_WORKSPACE_ID, + &reference.id, + Some("first-work"), + "replace-operation", + "replace-event", + "test", + TEST_CREATED_AT, + ) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "second"), + "second-work", + ); + let reopened = backend.show(reference.id.clone().into()).unwrap(); + let authority = case_authority_snapshot(&api); + case_response( + app, + signed_case_request(&identity, Some("second"), &reference.id, &input), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), reopened); + assert_eq!(case_authority_snapshot(&api), authority); +} + +// The generic dispatcher has no public UpdateCase route of its own. Compose its +// module boundary with the same real middleware, rather than inventing claims. +fn generic_case_router(api: WorkspaceApi) -> Router { + async fn update( + State(api): State, + axum::Extension(context): axum::Extension, + AxumPath((workspace, ticket)): AxumPath<(String, String)>, + headers: HeaderMap, + Json(request): Json, + ) -> ApiResult> { + let result = execute_ticket_rest_operation_with_context( + &api, + &workspace, + headers, + Some(&context), + TicketBackendOperation::UpdateCase { ticket, request }, + ) + .await?; + match result { + TicketBackendOperationResult::Ticket(ticket) => Ok(Json(ticket)), + _ => panic!("case dispatch returned a non-Ticket result"), + } + } + Router::new() + .route( + "/workspaces/{workspace_id}/tickets/{ticket_id}/case", + post(update), + ) + .with_state(api.clone()) + .layer(middleware::from_fn_with_state( + ServerApiContractService::Workspace(api.clone()), + attach_server_request_context, + )) + .layer(middleware::from_fn_with_state( + api, + authorize_workspace_api_request, + )) +} + +#[tokio::test] +async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_receipt() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let source = RuntimeWorkerRef::new("runtime-case", "owner"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Dispatch parity")) + .unwrap(); + seed_case_assignment(&api, &reference.id, &source, "dispatch-work"); + seed_worker_source_member(&api, "runtime-case", "other"); + let before = backend.show(reference.id.clone().into()).unwrap(); + let mut input = case_test_request( + &before, + "generic-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + input.author = Some("forged-client-actor".into()); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let generic = generic_case_router(api.clone()); + for worker in [Some("other"), None] { + case_response( + generic.clone(), + signed_case_request(&identity, worker, &reference.id, &input), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + } + let closed = case_response( + generic.clone(), + runtime_source_request( + &identity, + Some("owner"), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::OK, + ) + .await; + assert_eq!( + closed["case_history"][0]["author"], + "worker:runtime-case/owner" + ); + case_response( + build_router(api.clone()), + signed_case_request(&identity, Some("owner"), &reference.id, &input), + StatusCode::OK, + ) + .await; + assert_eq!( + case_response( + generic, + runtime_source_request( + &identity, + Some("owner"), + "POST", + &path, + serde_json::to_vec(&input).unwrap() + ), + StatusCode::OK + ) + .await, + closed + ); + let error = execute_ticket_rest_operation_with_context( + &api, + TEST_WORKSPACE_ID, + HeaderMap::new(), + None, + TicketBackendOperation::UpdateCase { + ticket: reference.id.clone(), + request: input, + }, + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); + assert_eq!( + serde_json::to_value(backend.show(reference.id.into()).unwrap()).unwrap(), + closed + ); +} + +#[tokio::test] +async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let token = seed_test_api_token(api.store.as_ref(), "case-human"); + let other_token = seed_test_api_token(api.store.as_ref(), "other-human"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Human case identity")) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let mut input = case_test_request( + &before, + "human-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + input.author = Some("forged-author".into()); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let human_request = |token: &str, typed: bool| { + let mut body = serde_json::to_value(&input).unwrap(); + if typed { + body.as_object_mut().unwrap().remove("author"); + } + Request::builder() + .method("POST") + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() + }; + let typed = build_router(api.clone()); + let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await; + let closed = backend.show(reference.id.clone().into()).unwrap(); + let event = closed.events.last().unwrap(); + assert_eq!(event.attributes["source_account_id"], "account-case-human"); + assert_eq!(event.attributes["source_user_id"], "user-case-human"); + assert_eq!( + event.author.as_deref(), + Some("workspace-user:[\"account-case-human\",\"user-case-human\"]") + ); + assert_eq!( + closed.case_history.last().unwrap().author, + event.author.clone().unwrap() + ); + assert_eq!( + case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await, + response + ); + case_response( + typed, + human_request(&other_token, true), + StatusCode::CONFLICT, + ) + .await; + // Generic dispatch receives an explicit untrusted author but must override it. + case_response( + generic_case_router(api.clone()), + human_request(&token, false), + StatusCode::OK, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), closed); + // A human reopen preserves the previous immutable judgment and its actor. + let reopen = case_test_request(&closed, "human-reopen", ticket::TicketCaseAction::Reopen); + let mut body = serde_json::to_value(reopen).unwrap(); + body.as_object_mut().unwrap().remove("author"); + case_response( + build_router(api.clone()), + Request::builder() + .method("POST") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + StatusCode::OK, + ) + .await; + let reopened = backend.show(reference.id.into()).unwrap(); + assert_eq!(reopened.meta.workflow_state, TicketState::Open); + assert_eq!(reopened.case_history[0], closed.case_history[0]); + assert_eq!( + reopened.events.last().unwrap().attributes["source_user_id"], + "user-case-human" + ); +} + +#[tokio::test] +async fn public_case_route_requires_valid_authentication_and_workspace_scope_before_writes() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Authenticated route")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "auth-work", + ); + let before = backend.show(reference.id.clone().into()).unwrap(); + let authority = case_authority_snapshot(&api); + let input = case_test_request( + &before, + "invalid-proof", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + let mut unsigned = signed_case_request(&identity, Some("owner"), &reference.id, &input); + unsigned + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + case_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + let attacker = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + case_response( + app.clone(), + signed_case_request(&attacker, Some("owner"), &reference.id, &input), + StatusCode::UNAUTHORIZED, + ) + .await; + let mut tampered = signed_case_request(&identity, Some("owner"), &reference.id, &input); + *tampered.body_mut() = Body::from("{}"); + case_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await; + let mut wrong_scope = signed_case_request(&identity, Some("owner"), &reference.id, &input); + *wrong_scope.uri_mut() = format!("/workspaces/other-workspace/tickets/{}/case", reference.id) + .parse() + .unwrap(); + case_response(app, wrong_scope, StatusCode::UNAUTHORIZED).await; + assert_eq!(backend.show(reference.id.into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); +} + +#[tokio::test] +async fn catalog_case_dispatch_authenticates_and_routes_the_canonical_workspace_path() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Catalog routing boundary")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "catalog-work", + ); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "catalog-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + // Retain only this in-process fixture: do not create a real Runtime or a + // background Orchestrator hook while testing catalog authentication/routing. + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); + let mut unsigned = signed_case_request(&identity, Some("owner"), &reference.id, &input); + unsigned + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + case_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + let response = case_response( + app, + signed_case_request(&identity, Some("owner"), &reference.id, &input), + StatusCode::OK, + ) + .await; + assert_eq!(response["workflow_state"], "closed"); + assert_eq!( + backend.show(reference.id.into()).unwrap().case_history[0].author, + "worker:runtime-case/owner" + ); +} diff --git a/docs/development/ticket-case-state.md b/docs/development/ticket-case-state.md index c1f7dbbd1..11df90b83 100644 --- a/docs/development/ticket-case-state.md +++ b/docs/development/ticket-case-state.md @@ -11,7 +11,7 @@ Ticketの現行案件状態は `open` / `closed` のみ。`open` は引き続き - `CorrectClosure { reason }`: closedの現在判断を訂正する。過去の判断は変更しない。 - 理由は `completed` / `not_planned` / `duplicate` / `unknown` / `{ other: string }`。`completed` だけが要求実現済みを表す。すべての操作に説明を必要とし、任意のtyped成果参照を保持する。 -`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。 +`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。Runtime Workerのidentityは検証済みHTTP contextを正本とし、任意のsource hint headersがなくても失わない。hintがある場合は検証済みidentityとの一致を確認し、Worker identityのないRuntime-only要求を人間の判断として扱わない。人間の操作も認証済みaccount/user identityを判断主体・再送に結び付ける。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。 ## 更新競合と再送