Merge pull request #250 from copenhagentruckwash/propose-fix-for-privilege-boundary-regression

Restrict studio simulation to config-version view and prevent auto-creating drafts
This commit is contained in:
Jeppe B
2026-06-01 23:52:28 +02:00
committed by GitHub
3 changed files with 97 additions and 34 deletions
@@ -517,21 +517,11 @@ class selfserve_studio_graph
throw new \RuntimeException('lane_id is required for studio simulation.');
}
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning();
if ($configSource === 'published') {
$version = $versioning->getPublishedV2Config($departmentId);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null;
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
$configSource = $configContext['config_source'];
$config = $configContext['config'];
$versionId = $configContext['version_id'];
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false;
@@ -592,6 +582,70 @@ class selfserve_studio_graph
);
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array{config_source:string,config:array<string,mixed>|null,version_id:int|null}
*/
private function loadSimulationConfig(
int $departmentId,
array $payload,
array $permissions,
selfserve_config_versioning $versioning
): array {
$configSource = $this->resolveSimulationConfigSource($payload, $permissions);
if ($configSource === 'published') {
$version = $versioning->getPublishedV2Config($departmentId);
return [
'config_source' => $configSource,
'config' => is_array($version['config'] ?? null) ? (array)$version['config'] : null,
'version_id' => isset($version['version_id']) ? (int)$version['version_id'] : null,
];
}
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus(
$departmentId,
selfserve_config_versioning::STATUS_DRAFT
);
if (!$draft->exists()) {
return [
'config_source' => $configSource,
'config' => $versioning->snapshotLegacyConfig($departmentId),
'version_id' => null,
];
}
$config = (array)($draft->config_json->value() ?? []);
if (!$versioning->isV2Config($config)) {
$config = $versioning->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
}
return [
'config_source' => $configSource,
'config' => $config,
'version_id' => (int)$draft->id,
];
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
*/
private function resolveSimulationConfigSource(array $payload, array $permissions): string
{
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
if ($configSource === 'draft' && !($permissions['can_view'] ?? false)) {
throw new \RuntimeException('Draft studio simulation requires list_department_selfserve_config_versions permission.');
}
return $configSource;
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
@@ -605,21 +659,11 @@ class selfserve_studio_graph
?callable $progressCallback = null
): array
{
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning();
if ($configSource === 'published') {
$version = $versioning->getPublishedV2Config($departmentId);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null;
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
$configSource = $configContext['config_source'];
$config = $configContext['config'];
$versionId = $configContext['version_id'];
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false;
@@ -109,7 +109,7 @@ class departmentSelfserveStudioRoute
$this->post('/department/selfserve/studio/simulate', function (): void {
global $response;
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department', 'lane_id', 'reg']);
$departmentId = (int)self::getParameter('department');
$laneId = (int)self::getParameter('lane_id');
@@ -129,11 +129,11 @@ class departmentSelfserveStudioRoute
$response->error($exception->getMessage(), 422);
}
}, [
'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator',
'list_department_selfserve_config_versions' => 'Run the self-serve studio simulator',
]);
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
@@ -169,12 +169,12 @@ class departmentSelfserveStudioRoute
}
exit;
}, [
'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress',
'list_department_selfserve_config_versions' => 'Stream grouped self-serve studio question path outcome progress',
]);
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
global $response;
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
@@ -192,7 +192,7 @@ class departmentSelfserveStudioRoute
$response->error($exception->getMessage(), 422);
}
}, [
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
'list_department_selfserve_config_versions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
@@ -120,7 +120,26 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
expect($studioRoute)->toContain('modules_shelly_config');
expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop');
$simulateStart = strpos($studioRoute, "/department/selfserve/studio/simulate");
$simulateEnd = strpos($studioRoute, "/department/selfserve/studio/path-outcomes/stream", $simulateStart);
expect($simulateStart)->not->toBeFalse();
expect($simulateEnd)->not->toBeFalse();
$simulateRoute = substr($studioRoute, $simulateStart, $simulateEnd - $simulateStart);
expect($simulateRoute)->toContain("requireStudioUser('list_department_selfserve_config_versions')");
expect($simulateRoute)->toContain("'list_department_selfserve_config_versions' => 'Run the self-serve studio simulator'");
expect($simulateRoute)->not->toContain('list_department_selfserve_vehicle_conditions');
expect($studioGraph)->not->toBeFalse();
$loadSimulationConfigStart = strpos($studioGraph, 'private function loadSimulationConfig');
$projectPathOutcomesStart = strpos($studioGraph, 'public function projectPathOutcomes', $loadSimulationConfigStart);
expect($loadSimulationConfigStart)->not->toBeFalse();
expect($projectPathOutcomesStart)->not->toBeFalse();
$loadSimulationConfig = substr($studioGraph, $loadSimulationConfigStart, $projectPathOutcomesStart - $loadSimulationConfigStart);
expect($loadSimulationConfig)->toContain('selectLatestByDepartmentAndStatus');
expect($loadSimulationConfig)->toContain('snapshotLegacyConfig($departmentId)');
expect($loadSimulationConfig)->not->toContain('ensureDraftFromLegacy');
expect($loadSimulationConfig)->toContain('Draft studio simulation requires list_department_selfserve_config_versions permission.');
expect($studioGraph)->toContain('department_selfserve_studio_layouts');
expect($studioGraph)->toContain('department_selfserve_path_confirmations');
expect($studioGraph)->toContain('buildGatewayWorkspace');