fix: allow workspace clients through ticket REST operations

This commit is contained in:
2026-08-22 19:43:18 +09:00
parent 30e4985f9a
commit 779ced82b1
+56 -38
View File
@@ -3842,7 +3842,7 @@ fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Res
Ok(()) Ok(())
} }
async fn execute_worker_ticket_rest_operation( async fn execute_ticket_rest_operation(
api: &WorkspaceApi, api: &WorkspaceApi,
workspace_id: &str, workspace_id: &str,
headers: HeaderMap, headers: HeaderMap,
@@ -3863,7 +3863,10 @@ async fn execute_worker_ticket_rest_operation(
let operation_kind = ticket_mutation_operation_kind(&operation); let operation_kind = ticket_mutation_operation_kind(&operation);
let is_mutation = operation_kind != "read"; let is_mutation = operation_kind != "read";
let target = ticket_mutation_target(&operation).cloned(); let target = ticket_mutation_target(&operation).cloned();
let source = authenticate_worker_mutation_source(api, workspace_id, &headers)?; // Human clients are authorized by the Workspace route boundary. Runtime-forwarded Worker
// calls carry source headers; when either header is present, the complete pair is required
// and authenticated before source attribution is attached.
let source = optional_worker_mutation_source(api, workspace_id, &headers)?;
reject_unguarded_ticket_completion(&operation)?; reject_unguarded_ticket_completion(&operation)?;
validate_ticket_repository_operation(api, &operation)?; validate_ticket_repository_operation(api, &operation)?;
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok()); let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
@@ -3871,9 +3874,12 @@ async fn execute_worker_ticket_rest_operation(
.as_ref() .as_ref()
.map(|ticket| ticket.meta.workflow_state.as_str().to_string()) .map(|ticket| ticket.meta.workflow_state.as_str().to_string())
.unwrap_or_else(|| ticket_operation_initial_state(&operation)); .unwrap_or_else(|| ticket_operation_initial_state(&operation));
bind_worker_ticket_operation_source(&source, &mut operation); if let Some(source) = source.as_ref() {
let source_context = worker_ticket_source_context(api, workspace_id, &source, before.as_ref()); bind_worker_ticket_operation_source(source, &mut operation);
backend = backend.with_event_attributes(source_context.attributes(operation_kind)); let source_context =
worker_ticket_source_context(api, workspace_id, source, before.as_ref());
backend = backend.with_event_attributes(source_context.attributes(operation_kind));
}
let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?;
if is_mutation if is_mutation
@@ -3886,7 +3892,7 @@ async fn execute_worker_ticket_rest_operation(
&ticket.meta.id, &ticket.meta.id,
&previous_state, &previous_state,
ticket.meta.workflow_state.as_str(), ticket.meta.workflow_state.as_str(),
Some(source), source,
); );
} }
Ok(result) Ok(result)
@@ -3899,7 +3905,7 @@ async fn execute_worker_ticket_test_operation(
headers: HeaderMap, headers: HeaderMap,
Json(operation): Json<TicketBackendOperation>, Json(operation): Json<TicketBackendOperation>,
) -> ApiResult<Json<TicketBackendOperationResult>> { ) -> ApiResult<Json<TicketBackendOperationResult>> {
execute_worker_ticket_rest_operation(&api, &path.workspace_id, headers, operation) execute_ticket_rest_operation(&api, &path.workspace_id, headers, operation)
.await .await
.map(Json) .map(Json)
} }
@@ -3935,7 +3941,7 @@ async fn scoped_default_intake_ready_body(
headers: HeaderMap, headers: HeaderMap,
Json(request): Json<DefaultIntakeReadyBodyRequest>, Json(request): Json<DefaultIntakeReadyBodyRequest>,
) -> ApiResult<Json<String>> { ) -> ApiResult<Json<String>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
headers, headers,
@@ -3972,7 +3978,7 @@ async fn scoped_list_ticket_summaries(
ticket::TicketListQuery::states(selected) ticket::TicketListQuery::states(selected)
} }
}; };
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
headers, headers,
@@ -3990,7 +3996,7 @@ async fn scoped_get_ticket_record(
AxumPath((workspace_id, id)): AxumPath<(String, String)>, AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
) -> ApiResult<Json<ticket::Ticket>> { ) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4020,7 +4026,7 @@ async fn scoped_create_ticket_record(
"Ticket creation must start in planning; use guarded workflow operations for later states", "Ticket creation must start in planning; use guarded workflow operations for later states",
)); ));
} }
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
headers, headers,
@@ -4039,7 +4045,7 @@ async fn scoped_edit_ticket_record_item(
headers: HeaderMap, headers: HeaderMap,
Json(edit): Json<TicketItemEdit>, Json(edit): Json<TicketItemEdit>,
) -> ApiResult<Json<ticket::Ticket>> { ) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4060,7 +4066,7 @@ async fn scoped_ticket_dependency_check(
AxumPath((workspace_id, id)): AxumPath<(String, String)>, AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
) -> ApiResult<Json<ticket::TicketDependencyCheck>> { ) -> ApiResult<Json<ticket::TicketDependencyCheck>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4081,7 +4087,7 @@ async fn scoped_add_ticket_thread_event(
headers: HeaderMap, headers: HeaderMap,
Json(event): Json<NewTicketEvent>, Json(event): Json<NewTicketEvent>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4100,7 +4106,7 @@ async fn scoped_add_ticket_state_change(
headers: HeaderMap, headers: HeaderMap,
Json(change): Json<TicketStateChange>, Json(change): Json<TicketStateChange>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4119,7 +4125,7 @@ async fn scoped_add_ticket_intake_summary(
headers: HeaderMap, headers: HeaderMap,
Json(summary): Json<ticket::TicketIntakeSummary>, Json(summary): Json<ticket::TicketIntakeSummary>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4147,7 +4153,7 @@ async fn scoped_set_ticket_state_field(
headers: HeaderMap, headers: HeaderMap,
Json(change): Json<TicketStateChange>, Json(change): Json<TicketStateChange>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4167,7 +4173,7 @@ async fn scoped_set_ticket_workflow_state(
headers: HeaderMap, headers: HeaderMap,
Json(change): Json<TicketStateChange>, Json(change): Json<TicketStateChange>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4186,7 +4192,7 @@ async fn scoped_mark_ticket_ready(
headers: HeaderMap, headers: HeaderMap,
Json(request): Json<TicketMarkReadyRequest>, Json(request): Json<TicketMarkReadyRequest>,
) -> ApiResult<Json<ticket::Ticket>> { ) -> ApiResult<Json<ticket::Ticket>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4212,7 +4218,7 @@ async fn scoped_queue_ticket_record(
AxumPath((workspace_id, id)): AxumPath<(String, String)>, AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4975,7 +4981,7 @@ async fn scoped_close_ticket_record(
headers: HeaderMap, headers: HeaderMap,
Json(resolution): Json<MarkdownText>, Json(resolution): Json<MarkdownText>,
) -> ApiResult<StatusCode> { ) -> ApiResult<StatusCode> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -4994,7 +5000,7 @@ async fn scoped_record_ticket_relation(
headers: HeaderMap, headers: HeaderMap,
Json(relation): Json<ticket::NewTicketRelation>, Json(relation): Json<ticket::NewTicketRelation>,
) -> ApiResult<Json<ticket::TicketRelation>> { ) -> ApiResult<Json<ticket::TicketRelation>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -5022,7 +5028,7 @@ async fn scoped_remove_ticket_relation(
headers: HeaderMap, headers: HeaderMap,
Json(relation): Json<TicketRelationRemoveRequest>, Json(relation): Json<TicketRelationRemoveRequest>,
) -> ApiResult<Json<ticket::TicketRelation>> { ) -> ApiResult<Json<ticket::TicketRelation>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -5051,7 +5057,7 @@ async fn scoped_query_ticket_relations(
headers: HeaderMap, headers: HeaderMap,
Json(query): Json<TicketRelationSearchRequest>, Json(query): Json<TicketRelationSearchRequest>,
) -> ApiResult<Json<Vec<ticket::TicketRelation>>> { ) -> ApiResult<Json<Vec<ticket::TicketRelation>>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
headers, headers,
@@ -5072,7 +5078,7 @@ async fn scoped_ticket_relation_view(
AxumPath((workspace_id, id)): AxumPath<(String, String)>, AxumPath((workspace_id, id)): AxumPath<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
) -> ApiResult<Json<ticket::TicketRelationView>> { ) -> ApiResult<Json<ticket::TicketRelationView>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -5093,7 +5099,7 @@ async fn scoped_record_ticket_orchestration_plan(
headers: HeaderMap, headers: HeaderMap,
Json(record): Json<ticket::NewOrchestrationPlanRecord>, Json(record): Json<ticket::NewOrchestrationPlanRecord>,
) -> ApiResult<Json<ticket::OrchestrationPlanRecord>> { ) -> ApiResult<Json<ticket::OrchestrationPlanRecord>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&workspace_id, &workspace_id,
headers, headers,
@@ -5121,7 +5127,7 @@ async fn scoped_query_ticket_orchestration_plans(
headers: HeaderMap, headers: HeaderMap,
Json(query): Json<TicketOrchestrationPlanSearchRequest>, Json(query): Json<TicketOrchestrationPlanSearchRequest>,
) -> ApiResult<Json<Vec<ticket::OrchestrationPlanRecord>>> { ) -> ApiResult<Json<Vec<ticket::OrchestrationPlanRecord>>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
headers, headers,
@@ -5142,7 +5148,7 @@ async fn scoped_ticket_doctor(
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
headers: HeaderMap, headers: HeaderMap,
) -> ApiResult<Json<ticket::TicketDoctorReport>> { ) -> ApiResult<Json<ticket::TicketDoctorReport>> {
let result = execute_worker_ticket_rest_operation( let result = execute_ticket_rest_operation(
&api, &api,
&path.workspace_id, &path.workspace_id,
headers, headers,
@@ -15390,14 +15396,9 @@ mod tests {
}, },
]; ];
for operation in operations { for operation in operations {
execute_worker_ticket_rest_operation( execute_ticket_rest_operation(&api, TEST_WORKSPACE_ID, source_headers(), operation)
&api, .await
TEST_WORKSPACE_ID, .unwrap();
source_headers(),
operation,
)
.await
.unwrap();
} }
let inputs = execution.take_inputs(); let inputs = execution.take_inputs();
@@ -18165,7 +18166,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() { async fn ticket_rest_search_allows_workspace_product_clients_and_rejects_invalid_worker_source()
{
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let app = build_router(api); let app = build_router(api);
@@ -18183,7 +18185,23 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::OK);
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/tickets"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&ticket::NewTicket::new("CLI Ticket")).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = app let response = app
.clone() .clone()