Preserve verified case judgment identity and canonical route authentication
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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/第三者承認や固定段階の通過を一律要求しない。
|
||||
|
||||
## 更新競合と再送
|
||||
|
||||
|
||||
Reference in New Issue
Block a user