Pilot migration of pages/admin/departments/edit($id).php onto the actionEditContext aggregator introduced in step 1. The CONTEXT-stage vorspiel (lookup → authorize → tenant-scope → can_view_page → viewAuth) collapses into a single declarative call; the POST branch (CSRF → SUBMIT-authorize → can_update gate → service call → PRG) stays callsite-specific as planned. Three deliberate touches beyond a 1:1 lift: * Additive aggregator extension: actionEditContext gains an optional notFoundFlashScopeKey arg so the dedup scope-key 'department_not_found' is preserved without widening the frozen actionResolveModelOrFail building-block signature. Pattern is documented as the forward-compatibility mechanism for future cluster migrations. * t() consistency: the not-found message now flows through t() via the aggregator. To avoid a partial-translation mix, Flash::success calls for 'Department updated' (×2) are also wrapped — German users now see fully translated messages instead of a German/English mix. * Defensive scope consumption: the action now consults $tenantScope['scope'] before falling through to the strict-mode fallback. The Departments policy never emits can_manage_all_tenants today (so behavior is identical), but the action is now resilient to future policies that might. New ActionContextCsrfPairingContractTest enforces actionRequireCsrf() before any POST body access for actions that use the aggregators — preventing CSRF-pairing regressions during the cluster-wide rollout (step 3). The step-1 testNoProductionCallSitesYet guard is removed, since departments-edit is now the first legitimate caller; the new pairing test takes over its protective role with a more substantive guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
6.4 KiB
PHP
158 lines
6.4 KiB
PHP
<?php
|
|
|
|
use MintyPHP\Buffer;
|
|
use MintyPHP\Http\SessionStoreInterface;
|
|
use MintyPHP\Router;
|
|
use MintyPHP\Service\Access\DepartmentAuthorizationPolicy;
|
|
use MintyPHP\Support\Flash;
|
|
use MintyPHP\Support\Guard;
|
|
|
|
$session = app(SessionStoreInterface::class)->all();
|
|
Guard::requireLogin();
|
|
$request = requestInput();
|
|
$returnTarget = requestResolveReturnTarget();
|
|
$closeTarget = requestResolveReturnTarget('admin/departments');
|
|
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
|
|
|
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
|
if ($currentUserId > 0) {
|
|
app(\MintyPHP\Service\Access\PermissionService::class)->getUserPermissions($currentUserId);
|
|
}
|
|
$departmentService = app(\MintyPHP\Service\Org\DepartmentService::class);
|
|
$tenantService = app(\MintyPHP\Service\Tenant\TenantService::class);
|
|
$directoryScopeGateway = app(\MintyPHP\Service\Tenant\TenantScopeService::class);
|
|
|
|
$uuid = trim((string) ($id ?? ''));
|
|
$editTarget = requestPathWithReturnTarget("admin/departments/edit/{$uuid}", $returnTarget);
|
|
|
|
// Resolve the department BEFORE the aggregator so the authorize-context can
|
|
// carry the real target_department_id (matches today's order: lookup → CONTEXT
|
|
// authorize → can_view_page). The aggregator receives a trivial finder that
|
|
// returns the already-loaded model so its not-found branch fires only when
|
|
// findByUuid returned null.
|
|
$department = $uuid !== '' ? $departmentService->findByUuid($uuid) : null;
|
|
$departmentId = (int) ($department['id'] ?? 0);
|
|
|
|
$context = actionEditContext([
|
|
'finder' => static fn (string $_id): mixed => $department,
|
|
'rawId' => $uuid,
|
|
'notFoundFlashKey' => 'Department not found',
|
|
'notFoundRedirectPath' => $closeTarget,
|
|
'notFoundFlashScopeKey' => 'department_not_found',
|
|
'abilityKey' => DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT,
|
|
'context' => [
|
|
'actor_user_id' => $currentUserId,
|
|
'target_department_id' => $departmentId,
|
|
],
|
|
'viewAuthFlags' => ['can_update_department', 'can_delete_department'],
|
|
'forbiddenStrategy' => 'redirect',
|
|
]);
|
|
$department = is_array($context['model']) ? $context['model'] : $department;
|
|
$capabilities = $context['capabilities'];
|
|
$tenantScope = $context['tenantScope'];
|
|
$viewAuth = $context['viewAuth'];
|
|
$canUpdateDepartment = (bool) ($capabilities['can_update_department'] ?? false);
|
|
$canDeleteDepartment = (bool) ($capabilities['can_delete_department'] ?? false);
|
|
$canManageAllTenants = $tenantScope['scope'] === 'all';
|
|
$allowedTenantIds = $tenantScope['ids'];
|
|
|
|
app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($department);
|
|
|
|
$errorBag = formErrors();
|
|
$errors = [];
|
|
$warnings = [];
|
|
$form = $department;
|
|
$tenants = $tenantService->list();
|
|
if ($canManageAllTenants) {
|
|
// No filtering — actor may pick any tenant.
|
|
} elseif ($allowedTenantIds) {
|
|
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
|
|
$tenantId = (int) ($tenant['id'] ?? 0);
|
|
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
|
|
}));
|
|
if (!in_array((int) ($form['tenant_id'] ?? 0), $allowedTenantIds, true)) {
|
|
$form['tenant_id'] = 0;
|
|
}
|
|
} elseif ($directoryScopeGateway->isStrict()) {
|
|
$tenants = [];
|
|
$form['tenant_id'] = 0;
|
|
}
|
|
|
|
if ($request->isMethod('POST') && !actionRequireCsrf($editTarget, $editTarget, 'csrf_expired')) {
|
|
return;
|
|
}
|
|
|
|
if ($request->isMethod('POST')) {
|
|
$submitDecision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT, [
|
|
'actor_user_id' => $currentUserId,
|
|
'target_department_id' => $departmentId,
|
|
'input' => $request->bodyAll(),
|
|
]);
|
|
if (!$submitDecision->isAllowed()) {
|
|
Router::redirect('error/forbidden');
|
|
return;
|
|
}
|
|
|
|
$submitCapabilities = $submitDecision->attribute('capabilities', []);
|
|
if (is_array($submitCapabilities)) {
|
|
$canUpdateDepartment = (bool) ($submitCapabilities['can_update_department'] ?? $canUpdateDepartment);
|
|
$canDeleteDepartment = (bool) ($submitCapabilities['can_delete_department'] ?? $canDeleteDepartment);
|
|
$viewAuth['page'] = [
|
|
'can_update_department' => $canUpdateDepartment,
|
|
'can_delete_department' => $canDeleteDepartment,
|
|
];
|
|
}
|
|
if (!$canUpdateDepartment) {
|
|
Router::redirect('error/forbidden');
|
|
return;
|
|
}
|
|
|
|
$selectedTenantId = $request->bodyInt('tenant_id');
|
|
if ($canManageAllTenants) {
|
|
// No restriction — submitted tenant_id passes through.
|
|
} elseif ($allowedTenantIds) {
|
|
$selectedTenantId = in_array($selectedTenantId, $allowedTenantIds, true) ? $selectedTenantId : 0;
|
|
} elseif ($directoryScopeGateway->isStrict()) {
|
|
$selectedTenantId = 0;
|
|
}
|
|
$input = $request->bodyAll();
|
|
$input['tenant_id'] = $selectedTenantId;
|
|
|
|
$result = $departmentService->updateFromAdmin($departmentId, $input, $currentUserId);
|
|
$form = $result['form'] ?? $form;
|
|
$errorBag->merge($result['errors'] ?? []);
|
|
$warnings = $result['warnings'] ?? [];
|
|
$form['tenant_id'] = $selectedTenantId;
|
|
|
|
if (($result['ok'] ?? false) && !$errorBag->hasAny()) {
|
|
$cleaned = $departmentService->cleanupUserAssignments($departmentId);
|
|
$action = (string) $request->body('action', 'save');
|
|
if ($cleaned > 0) {
|
|
Flash::info(t('Department assignments cleaned: %d', $cleaned), $editTarget, 'department_assignments_cleaned');
|
|
}
|
|
if ($action === 'save_close') {
|
|
if ($warnings) {
|
|
Flash::info(implode(' ', $warnings), $closeTarget, 'department_warning');
|
|
}
|
|
Flash::success(t('Department updated'), $closeTarget, 'department_updated');
|
|
Router::redirect($closeTarget);
|
|
} else {
|
|
if ($warnings) {
|
|
Flash::info(implode(' ', $warnings), $editTarget, 'department_warning');
|
|
}
|
|
Flash::success(t('Department updated'), $editTarget, 'department_updated');
|
|
Router::redirect($editTarget);
|
|
}
|
|
}
|
|
}
|
|
|
|
$validationSummaryErrors = $errorBag->toArray();
|
|
$errors = $errorBag->toFlatList();
|
|
$titleText = $canUpdateDepartment ? t('Edit department') : t('View department');
|
|
Buffer::set('title', $titleText);
|
|
$breadcrumbs = [
|
|
['label' => t('Home'), 'path' => 'admin'],
|
|
['label' => t('Departments'), 'path' => 'admin/departments'],
|
|
['label' => $titleText],
|
|
];
|