fix: enforce assignment workspace references

This commit is contained in:
2026-08-20 11:06:31 +09:00
parent 4583b512b3
commit 560226dea2
4 changed files with 343 additions and 7 deletions
+9
View File
@@ -1082,6 +1082,11 @@ mod tests {
) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')",
[worker_id().to_string()],
)?;
c.execute(
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)",
[],
)?;
Ok(())
})
.unwrap();
@@ -1267,7 +1272,11 @@ mod tests {
fn purge_tombstone_commit_is_idempotent() {
let s = setup();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?;
conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?;
Ok(())
}).unwrap();
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
+51
View File
@@ -14556,6 +14556,21 @@ mod tests {
.create(ticket::NewTicket::new("Assigned Ticket"))
.unwrap();
let ticket_id = created.id;
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new("embedded", "42"),
display_name: "Worker 42".to_string(),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
let assignment = TicketWorkerAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
ticket_id: ticket_id.clone(),
@@ -14650,6 +14665,24 @@ mod tests {
.unwrap()
.worker
.unwrap();
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(
EMBEDDED_WORKER_RUNTIME_ID,
source_worker.worker.worker_id.clone(),
),
display_name: "Source Worker".to_string(),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
let recipient_worker = api
.runtime
.spawn_worker(
@@ -14660,6 +14693,24 @@ mod tests {
.unwrap()
.worker
.unwrap();
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(
EMBEDDED_WORKER_RUNTIME_ID,
recipient_worker.worker.worker_id.clone(),
),
display_name: "Recipient Worker".to_string(),
profile: Some("builtin:coder".to_string()),
retention_state: "normal".to_string(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.unwrap();
let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend
.create(ticket::NewTicket::new("Notify assigned Worker"))
+281 -6
View File
@@ -1075,6 +1075,7 @@ impl SqliteWorkspaceStore {
Error::Store(format!("Merge Request schema verification failed: {error}"))
})?;
validate_workspace_resource_references(&conn)?;
verify_workspace_resource_constraints(&conn)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
@@ -5128,6 +5129,30 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
Ok(())
}
fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? < 39 {
return Ok(());
}
for trigger in [
"ticket_worker_assignments_validate_insert",
"ticket_worker_assignments_validate_update",
"ticket_worker_assignment_events_validate_insert",
"ticket_assignment_operations_validate_insert",
] {
let exists = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'trigger' AND name = ?1)",
[trigger],
|row| row.get::<_, bool>(0),
)?;
if !exists {
return Err(Error::Store(format!(
"Workspace resource constraint trigger `{trigger}` is missing"
)));
}
}
Ok(())
}
fn validate_workspace_resource_references(conn: &Connection) -> Result<()> {
let diagnostics = workspace_resource_reference_diagnostics(conn)?;
if diagnostics.is_empty() {
@@ -5211,6 +5236,55 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
WHERE ticket.workspace_id = link.workspace_id \
AND ticket.ticket_id = link.ticket_id) LIMIT 100",
),
(
"ticket_worker_assignments.ticket_id",
"SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.ticket_id \
FROM ticket_worker_assignments AS assignment \
WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \
WHERE ticket.workspace_id = assignment.workspace_id \
AND ticket.ticket_id = assignment.ticket_id) LIMIT 100",
),
(
"ticket_worker_assignments.worker_id",
"SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \
FROM ticket_worker_assignments AS assignment \
WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \
WHERE worker.workspace_id = assignment.workspace_id \
AND worker.runtime_id = assignment.runtime_id \
AND worker.worker_id = assignment.worker_id) LIMIT 100",
),
(
"ticket_current_worker_assignments.assignment_id",
"SELECT current.workspace_id || '/' || current.assignment_id \
FROM ticket_current_worker_assignments AS current \
WHERE NOT EXISTS (SELECT 1 FROM ticket_worker_assignments AS assignment \
WHERE assignment.workspace_id = current.workspace_id \
AND assignment.ticket_id = current.ticket_id \
AND assignment.assignment_id = current.assignment_id \
AND assignment.runtime_id = current.runtime_id \
AND assignment.worker_id = current.worker_id) LIMIT 100",
),
(
"ticket_worker_assignment_events.assignment_id",
"SELECT event.workspace_id || '/' || event.event_id \
FROM ticket_worker_assignment_events AS event \
WHERE (event.assignment_id IS NOT NULL AND NOT EXISTS (\
SELECT 1 FROM ticket_worker_assignments AS assignment \
WHERE assignment.workspace_id = event.workspace_id \
AND assignment.assignment_id = event.assignment_id)) \
OR (event.previous_assignment_id IS NOT NULL AND NOT EXISTS (\
SELECT 1 FROM ticket_worker_assignments AS assignment \
WHERE assignment.workspace_id = event.workspace_id \
AND assignment.assignment_id = event.previous_assignment_id)) LIMIT 100",
),
(
"ticket_assignment_operations.ticket_id",
"SELECT operation.workspace_id || '/' || operation.operation_id || ' -> ' || operation.ticket_id \
FROM ticket_assignment_operations AS operation \
WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \
WHERE ticket.workspace_id = operation.workspace_id \
AND ticket.ticket_id = operation.ticket_id) LIMIT 100",
),
(
"artifacts.ticket_id",
"SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \
@@ -5247,6 +5321,16 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
{
continue;
}
if sql.contains("assignment.worker_id")
&& !column_exists(conn, "ticket_worker_assignments", "worker_id")?
{
continue;
}
if sql.contains("current.worker_id")
&& !column_exists(conn, "ticket_current_worker_assignments", "worker_id")?
{
continue;
}
collect_reference_diagnostics(conn, sql, label, &mut diagnostics)?;
}
Ok(diagnostics)
@@ -6156,7 +6240,9 @@ CREATE TABLE ticket_worker_assignments_v39 (
assigned_by TEXT NOT NULL,
assigned_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, assignment_id),
UNIQUE (workspace_id, ticket_id, assignment_id)
UNIQUE (workspace_id, ticket_id, assignment_id),
UNIQUE (workspace_id, ticket_id, assignment_id, runtime_id, worker_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT INTO ticket_worker_assignments_v39 SELECT * FROM ticket_worker_assignments;
@@ -6169,7 +6255,8 @@ CREATE TABLE ticket_worker_assignment_events_v39 (
previous_assignment_id TEXT,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, event_id)
PRIMARY KEY (workspace_id, event_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT INTO ticket_worker_assignment_events_v39 SELECT * FROM ticket_worker_assignment_events;
@@ -6182,9 +6269,10 @@ CREATE TABLE ticket_current_worker_assignments_v39 (
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id),
UNIQUE (workspace_id, runtime_id, worker_id),
FOREIGN KEY (workspace_id, ticket_id, assignment_id)
REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id)
ON DELETE CASCADE
FOREIGN KEY (workspace_id, ticket_id, assignment_id, runtime_id, worker_id)
REFERENCES ticket_worker_assignments(
workspace_id, ticket_id, assignment_id, runtime_id, worker_id
) ON DELETE CASCADE
);
INSERT INTO ticket_current_worker_assignments_v39 SELECT * FROM ticket_current_worker_assignments;
@@ -6199,7 +6287,8 @@ CREATE TABLE ticket_assignment_operations_v39 (
expected_assignment_id TEXT,
created_at TEXT NOT NULL,
request_fingerprint TEXT,
PRIMARY KEY (workspace_id, operation_id)
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT INTO ticket_assignment_operations_v39 SELECT * FROM ticket_assignment_operations;
@@ -6336,6 +6425,73 @@ CREATE INDEX IF NOT EXISTS idx_workspace_resource_human_keys_reverse
))
})?;
}
// Assignment rows and events are historical evidence and intentionally survive Ticket or
// Worker retention deletion, so parent FKs would impose the wrong delete semantics. These
// triggers provide the equivalent database-layer insertion boundary: every new assignment
// resolves both authorities in the same Workspace, and event references resolve a committed
// assignment. Operation assignment/Worker ids remain unconstrained because reservations are
// persisted before assignment/Worker creation and expected ids may intentionally be stale.
conn.execute_batch(
r#"
CREATE TRIGGER ticket_worker_assignments_validate_insert
BEFORE INSERT ON ticket_worker_assignments
WHEN NOT EXISTS (
SELECT 1 FROM typed_tickets AS ticket
WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id
)
OR NOT EXISTS (
SELECT 1 FROM worker_registry AS worker
WHERE worker.workspace_id = NEW.workspace_id
AND worker.runtime_id = NEW.runtime_id
AND worker.worker_id = NEW.worker_id
)
BEGIN
SELECT RAISE(ABORT, 'ticket_worker_assignments must reference a Ticket and Worker in the same Workspace');
END;
CREATE TRIGGER ticket_worker_assignments_validate_update
BEFORE UPDATE OF workspace_id, ticket_id, runtime_id, worker_id ON ticket_worker_assignments
WHEN NOT EXISTS (
SELECT 1 FROM typed_tickets AS ticket
WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id
)
OR NOT EXISTS (
SELECT 1 FROM worker_registry AS worker
WHERE worker.workspace_id = NEW.workspace_id
AND worker.runtime_id = NEW.runtime_id
AND worker.worker_id = NEW.worker_id
)
BEGIN
SELECT RAISE(ABORT, 'ticket_worker_assignments must reference a Ticket and Worker in the same Workspace');
END;
CREATE TRIGGER ticket_worker_assignment_events_validate_insert
BEFORE INSERT ON ticket_worker_assignment_events
WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ticket_worker_assignments AS assignment
WHERE assignment.workspace_id = NEW.workspace_id
AND assignment.assignment_id = NEW.assignment_id
))
OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ticket_worker_assignments AS assignment
WHERE assignment.workspace_id = NEW.workspace_id
AND assignment.assignment_id = NEW.previous_assignment_id
))
BEGIN
SELECT RAISE(ABORT, 'ticket_worker_assignment_events must reference assignments in the same Workspace');
END;
CREATE TRIGGER ticket_assignment_operations_validate_insert
BEFORE INSERT ON ticket_assignment_operations
WHEN NOT EXISTS (
SELECT 1 FROM typed_tickets AS ticket
WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id
)
BEGIN
SELECT RAISE(ABORT, 'ticket_assignment_operations must reference a Ticket in the same Workspace');
END;
"#,
)?;
Ok(())
}
@@ -8114,6 +8270,31 @@ INSERT INTO worker_registry (
assert!(error.contains("refusing to serve"), "{error}");
}
#[test]
fn startup_rejects_missing_workspace_resource_constraint_trigger() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER ticket_worker_assignments_validate_insert")?;
Ok(())
})
.unwrap();
drop(store);
let error = match SqliteWorkspaceStore::open(&path) {
Ok(_) => panic!("missing assignment constraint trigger must fail closed"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("ticket_worker_assignments_validate_insert"),
"{error}"
);
}
#[test]
fn migration_plan_lists_workspace_reference_violations_without_mutating_source() {
let temp = tempfile::tempdir().unwrap();
@@ -8243,6 +8424,17 @@ INSERT INTO typed_ticket_relations (
INSERT INTO objective_ticket_links (
workspace_id, objective_id, ticket_id, kind, created_at
) VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01');
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES
('workspace-a', 'runtime-a', '00000000-0000-7000-8000-000000000001', 'Worker A', 'normal', '2026-01-01', '2026-01-01'),
('workspace-b', 'runtime-b', '00000000-0000-7000-8000-000000000002', 'Worker B', 'normal', '2026-01-01', '2026-01-01');
INSERT INTO ticket_worker_assignments (
workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at
) VALUES (
'workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a',
'00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01'
);
"#,
)
.unwrap();
@@ -8257,12 +8449,19 @@ INSERT INTO objective_ticket_links (
error.contains("objective_ticket_links.objective_id"),
"{error}"
);
assert!(
error.contains("ticket_worker_assignments.worker_id"),
"{error}"
);
assert!(error.contains("assignment-cross-worker"), "{error}");
assert_eq!(current_schema_version(&conn).unwrap(), 38);
conn.execute("DELETE FROM typed_ticket_relations", [])
.unwrap();
conn.execute("DELETE FROM objective_ticket_links", [])
.unwrap();
conn.execute("DELETE FROM ticket_worker_assignments", [])
.unwrap();
apply_migrations_through(&conn, 39).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 39);
@@ -8286,6 +8485,53 @@ INSERT INTO objective_ticket_links (
[],
);
assert!(cross_objective_link.is_err());
let cross_ticket_assignment = conn.execute(
"INSERT INTO ticket_worker_assignments \
(workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \
VALUES ('workspace-b', 'ticket-a', 'assignment-cross-ticket', 'runtime-b', \
'00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')",
[],
);
assert!(cross_ticket_assignment.is_err());
let cross_worker_assignment = conn.execute(
"INSERT INTO ticket_worker_assignments \
(workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \
VALUES ('workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', \
'00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01')",
[],
);
assert!(cross_worker_assignment.is_err());
conn.execute(
"INSERT INTO ticket_worker_assignments \
(workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \
VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-b', \
'00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')",
[],
)
.unwrap();
let mismatched_current_assignment = conn.execute(
"INSERT INTO ticket_current_worker_assignments \
(workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at) \
VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-a', \
'00000000-0000-7000-8000-000000000001', '2026-01-01')",
[],
);
assert!(mismatched_current_assignment.is_err());
let cross_assignment_event = conn.execute(
"INSERT INTO ticket_worker_assignment_events \
(workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at) \
VALUES ('workspace-a', 'ticket-a', 'event-cross-assignment', 'assigned', \
'assignment-b', 'tester', '2026-01-01')",
[],
);
assert!(cross_assignment_event.is_err());
let cross_operation_ticket = conn.execute(
"INSERT INTO ticket_assignment_operations \
(workspace_id, operation_id, action, ticket_id, created_at) \
VALUES ('workspace-b', 'operation-cross-ticket', 'assign', 'ticket-a', '2026-01-01')",
[],
);
assert!(cross_operation_ticket.is_err());
assert!(
conn.execute(
"DELETE FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'repo-a'",
@@ -8321,6 +8567,26 @@ INSERT INTO objective_ticket_links (
.unwrap(),
0
);
conn.execute(
"DELETE FROM worker_registry WHERE workspace_id = 'workspace-b' AND worker_id = '00000000-0000-7000-8000-000000000002'",
[],
)
.unwrap();
conn.execute(
"DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'",
[],
)
.unwrap();
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
1,
"historical assignments survive Worker and Ticket retention deletion"
);
conn.execute(
"DELETE FROM workspaces WHERE workspace_id = 'workspace-b'",
[],
@@ -8335,6 +8601,15 @@ INSERT INTO objective_ticket_links (
.unwrap(),
0
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
0
);
let foreign_key_failures: i64 = conn
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
@@ -20,7 +20,8 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and assignment references;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; reservation operation ids remain intentionally unconstrained until their resources exist;
- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and
- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.