auth: enforce workspace worker credentials over ticket REST

This commit is contained in:
2026-08-01 17:23:59 +09:00
parent 3412f1c0ed
commit 81e631e640
11 changed files with 1853 additions and 283 deletions
+336 -50
View File
@@ -12,10 +12,10 @@ use std::{
use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
TicketStateChange, TicketSummary,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
@@ -387,57 +387,310 @@ impl WorkspaceHttpTicketBackend {
Self { client }
}
fn endpoint(&self) -> String {
format!(
"/api/w/{}/tickets/backend",
self.client.workspace_id().unwrap_or_default()
)
}
fn invoke(
&self,
operation: TicketBackendOperation,
) -> TicketResult<TicketBackendOperationResult> {
let client = self.client.clone();
let endpoint = self.endpoint();
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
if tokio::runtime::Handle::try_current().is_ok() {
return std::thread::spawn(move || Self::invoke_client(client, endpoint, operation))
.join()
.map_err(|_| {
TicketError::Conflict("ticket backend request thread panicked".to_string())
})?;
return std::thread::spawn(move || {
Self::invoke_client(client, workspace_id, operation)
})
.join()
.map_err(|_| {
TicketError::Conflict("ticket REST request thread panicked".to_string())
})?;
}
Self::invoke_client(client, endpoint, operation)
Self::invoke_client(client, workspace_id, operation)
}
fn ticket_path(id: &TicketIdOrSlug) -> String {
let value = match id {
TicketIdOrSlug::Id(value)
| TicketIdOrSlug::Slug(value)
| TicketIdOrSlug::Query(value) => value,
};
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
use std::fmt::Write as _;
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
fn request<T: serde::de::DeserializeOwned>(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
None => WorkspaceRequest {
method,
path: endpoint,
body: None,
},
};
let response = client.execute(request).map_err(|error| {
TicketError::Conflict(format!("ticket REST request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}",
response.status, response.body
)));
}
serde_json::from_str(&response.body)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_unit(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<TicketBackendOperationResult> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None => WorkspaceRequest {
method,
path: endpoint,
body: None,
},
};
let response = client.execute(request).map_err(|error| {
TicketError::Conflict(format!("ticket REST request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(TicketBackendOperationResult::Unit)
}
fn invoke_client(
client: Arc<dyn WorkspaceClient>,
endpoint: String,
workspace_id: String,
operation: TicketBackendOperation,
) -> TicketResult<TicketBackendOperationResult> {
let body = serde_json::to_string(&operation).map_err(|error| {
TicketError::Conflict(format!("serialize ticket operation: {error}"))
})?;
let response = client
.execute(WorkspaceRequest::json(
let base = format!("/api/w/{workspace_id}/tickets");
match operation {
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
let value = Self::request::<String>(
client,
WorkspaceRequestMethod::Post,
format!("{base}/default-intake-ready-body"),
Some(serde_json::json!({ "from": from })),
)?;
Ok(TicketBackendOperationResult::Text(value))
}
TicketBackendOperation::List { filter } => {
let state = match filter.state {
ticket::TicketStateSelector::Active => "active".to_string(),
ticket::TicketStateSelector::All => "all".to_string(),
ticket::TicketStateSelector::States(states) => states
.into_iter()
.map(|state| state.as_str().to_string())
.collect::<Vec<_>>()
.join(","),
};
let tickets = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/search?state={state}"),
None,
)?;
Ok(TicketBackendOperationResult::Tickets(tickets))
}
TicketBackendOperation::Show { id } => {
let ticket = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/record", Self::ticket_path(&id)),
None,
)?;
Ok(TicketBackendOperationResult::Ticket(ticket))
}
TicketBackendOperation::Create { input } => {
let ticket = Self::request(
client,
WorkspaceRequestMethod::Post,
base,
Some(serde_json::to_value(input).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket create: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::TicketRef(ticket))
}
TicketBackendOperation::EditItem { id, edit } => {
let ticket = Self::request(
client,
WorkspaceRequestMethod::Patch,
format!("{base}/{}/item", Self::ticket_path(&id)),
Some(serde_json::to_value(edit).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket edit: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::Ticket(ticket))
}
TicketBackendOperation::DependencyCheck { id } => {
let check = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/dependency-check", Self::ticket_path(&id)),
None,
)?;
Ok(TicketBackendOperationResult::DependencyCheck(check))
}
TicketBackendOperation::AddEvent { id, event } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
endpoint,
body,
))
.map_err(|error| {
TicketError::Conflict(format!("ticket backend request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket backend returned HTTP {}: {}",
response.status, response.body
)));
}
match serde_json::from_str::<TicketBackendHttpResponse>(&response.body).map_err(
|error| TicketError::Conflict(format!("decode ticket backend response: {error}")),
)? {
TicketBackendHttpResponse::Ok { result } => Ok(result),
TicketBackendHttpResponse::Error { message } => Err(TicketError::Conflict(message)),
format!("{base}/{}/thread-events", Self::ticket_path(&id)),
Some(serde_json::to_value(event).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket event: {error}"))
})?),
),
TicketBackendOperation::AddStateChanged { id, change } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/state-changes", Self::ticket_path(&id)),
Some(serde_json::to_value(change).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket state change: {error}"))
})?),
),
TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-summaries", Self::ticket_path(&id)),
Some(serde_json::to_value(summary).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket intake summary: {error}"))
})?),
),
TicketBackendOperation::SetStateField { id, field, change } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!(
"{base}/{}/state-fields/{}",
Self::ticket_path(&id),
Self::ticket_path(&TicketIdOrSlug::Query(field))
),
Some(serde_json::to_value(change).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket state field change: {error}"))
})?),
),
TicketBackendOperation::SetWorkflowState { id, change } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow-state", Self::ticket_path(&id)),
Some(serde_json::to_value(change).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
})?),
),
TicketBackendOperation::MarkIntakeReady {
id,
summary,
change,
} => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
Some(serde_json::json!({ "summary": summary, "change": change })),
),
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
None,
),
TicketBackendOperation::Review { id, review } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
Some(serde_json::to_value(review).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket review: {error}"))
})?),
),
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/workflow/close", Self::ticket_path(&id)),
Some(serde_json::to_value(resolution).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket close: {error}"))
})?),
),
TicketBackendOperation::AddTicketRelation { id, relation } => {
let relation = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/relations", Self::ticket_path(&id)),
Some(serde_json::to_value(relation).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::Relation(relation))
}
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
let relations = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/relations/search"),
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
)?;
Ok(TicketBackendOperationResult::Relations(relations))
}
TicketBackendOperation::RelationView { id } => {
let view = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/relation-view", Self::ticket_path(&id)),
None,
)?;
Ok(TicketBackendOperationResult::RelationView(view))
}
TicketBackendOperation::AddOrchestrationPlanRecord { id, record } => {
let record = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/orchestration-plans", Self::ticket_path(&id)),
Some(serde_json::to_value(record).map_err(|error| {
TicketError::Conflict(format!(
"serialize Ticket orchestration plan: {error}"
))
})?),
)?;
Ok(TicketBackendOperationResult::OrchestrationPlanRecord(
record,
))
}
TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind } => {
let records = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/orchestration-plans/search"),
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
)?;
Ok(TicketBackendOperationResult::OrchestrationPlanRecords(
records,
))
}
TicketBackendOperation::Doctor => {
let report = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/doctor"),
None,
)?;
Ok(TicketBackendOperationResult::DoctorReport(report))
}
}
}
}
@@ -1129,7 +1382,42 @@ provider = "github"
})
.unwrap_err();
assert!(error.to_string().contains("ticket backend request failed"));
assert!(error.to_string().contains("ticket REST request failed"));
}
#[test]
fn workspace_http_backend_posts_ticket_event_subresource() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let size = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..size]);
assert!(
request
.starts_with("POST /api/w/workspace-a/tickets/01TEST/thread-events HTTP/1.1")
);
assert!(!request.contains("\"operation\""));
assert!(request.contains("\"kind\":\"comment\""));
stream
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
.unwrap();
});
let client = Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
"workspace-a",
format!("http://{address}"),
"worker-a",
));
let backend = WorkspaceHttpTicketBackend::new(client);
backend
.add_event(
TicketIdOrSlug::Id("01TEST".to_string()),
NewTicketEvent::new(ticket::TicketEventKind::Comment, "REST comment"),
)
.unwrap();
server.join().unwrap();
}
#[test]
@@ -1141,15 +1429,13 @@ provider = "github"
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("POST /api/w/workspace-a/tickets/backend HTTP/1.1"));
assert!(request.contains("\"operation\":\"create\""));
assert!(request.starts_with("POST /api/w/workspace-a/tickets HTTP/1.1"));
assert!(!request.contains("\"operation\""));
assert!(request.contains("\"title\":\"HTTP ticket\""));
let response_body = serde_json::to_string(&TicketBackendHttpResponse::Ok {
result: TicketBackendOperationResult::TicketRef(TicketRef {
id: "01TEST".to_string(),
slug: "http-ticket".to_string(),
status: ticket::TicketStatus::Open,
}),
let response_body = serde_json::to_string(&TicketRef {
id: "01TEST".to_string(),
slug: "http-ticket".to_string(),
status: ticket::TicketStatus::Open,
})
.unwrap();
write!(