Preserve verified case judgment identity and canonical route authentication

This commit is contained in:
2026-10-11 03:36:27 +09:00
parent 4dd5207d1e
commit 7f03db9d79
4 changed files with 894 additions and 12 deletions
+116 -5
View File
@@ -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<server_api::TicketSummary, server_api::RepositoryApiError> {
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<TicketBackendOperationResult> {
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<WorkspaceApi>,
headers: HeaderMap,
path: AxumPath<(String, String)>,
request: Json<ticket::TicketCaseUpdate>,
) -> ApiResult<Json<ticket::Ticket>> {
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<WorkspaceApi>,
headers: HeaderMap,
context: &server_api::ServerRequestContext,
path: AxumPath<(String, String)>,
request: Json<ticket::TicketCaseUpdate>,
) -> ApiResult<Json<ticket::Ticket>> {
// 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<WorkspaceApi>,
source: Option<WorkerMutationSource>,
actor: Option<&RequestActor>,
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
Json(mut request): Json<ticket::TicketCaseUpdate>,
) -> ApiResult<Json<ticket::Ticket>> {
@@ -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");
}
@@ -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
@@ -0,0 +1,758 @@
use super::*;
async fn case_response(
app: Router,
request: Request<Body>,
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<Body> {
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<Vec<Vec<rusqlite::types::Value>>> {
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::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.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<WorkspaceApi>,
axum::Extension(context): axum::Extension<server_api::ServerRequestContext>,
AxumPath((workspace, ticket)): AxumPath<(String, String)>,
headers: HeaderMap,
Json(request): Json<ticket::TicketCaseUpdate>,
) -> ApiResult<Json<ticket::Ticket>> {
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"
);
}
+1 -1
View File
@@ -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/第三者承認や固定段階の通過を一律要求しない。
## 更新競合と再送