forked from fa/breadcrumb-the-shire
Cluster-2 pilot — first migration of a standard edit action whose
tenant-scope semantics use null = "manage all" (instead of the
boolean-flag pattern in cluster 1). The CONTEXT-stage vorspiel
collapses into one declarative actionEditContext call; everything
below the vorspiel (conditional audit, custom fields, security
artifacts, two-level submit-authorize, mergeTenantIdsPreservingOutOfScope,
post-save theme/locale/session hooks) stays callsite — domain logic.
Confirms the analyst hypothesis: actionEditContext +
tenantScopeFlagKey:'can_manage_tenants' is enough — no helper
extension. The override key was built in step 1, unit-tested at the
building-block level, and now production-validated.
Two callsite tenant-filter rewrites (GET line 98-105, POST line
190-208) replace is_array($allowedTenantIds) with
$tenantScope['scope']/$tenantScope['ids'] discrimination.
mergeTenantIdsPreservingOutOfScope still receives a list<int> — only
the argument source shifts; the function itself is unchanged.
Three drift decisions reproduced: notFoundFlashScopeKey:'user_not_found',
t() consistency on Flash::success('User updated'), defensive
$canManageAllTenants = $tenantScope['scope'] === 'all'. The legacy
$canManageTenants capability boolean stays alongside (it still gates
strict-mode fallback — both variables now coexist by design).
DetailDrawerFragmentContractTest gets an additive recognizer for
actionEditContext / actionCreateContext / actionFragmentContext
ability-key extraction. Without it the test couldn't see the
aggregator-mediated authorize call in users-edit, so the auth-parity
check against users/view-fragment would regress. Pure addition; the
legacy direct-authorize() regex path is untouched.
ActionContextCsrfPairingContractTest now covers five callers
(departments, tenants, roles, permissions, users) and stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
331 lines
15 KiB
PHP
331 lines
15 KiB
PHP
<?php
|
|
|
|
use MintyPHP\Buffer;
|
|
use MintyPHP\Http\SessionStoreInterface;
|
|
use MintyPHP\I18n;
|
|
use MintyPHP\Repository\Auth\PasswordResetRepository;
|
|
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
|
use MintyPHP\Router;
|
|
use MintyPHP\Service\Access\UserAuthorizationPolicy;
|
|
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
|
|
use MintyPHP\Support\Flash;
|
|
use MintyPHP\Support\Guard;
|
|
|
|
$sessionStore = app(SessionStoreInterface::class);
|
|
$session = $sessionStore->all();
|
|
|
|
Guard::requireLogin();
|
|
$request = requestInput();
|
|
$returnTarget = requestResolveReturnTarget();
|
|
$closeTarget = requestResolveReturnTarget('admin/users');
|
|
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
|
|
$userAssignmentService = app(\MintyPHP\Service\User\UserAssignmentService::class);
|
|
$userTenantRepository = app(\MintyPHP\Repository\Tenant\UserTenantRepository::class);
|
|
$userRoleRepository = app(\MintyPHP\Repository\Access\UserRoleRepository::class);
|
|
$userDepartmentRepository = app(\MintyPHP\Repository\Org\UserDepartmentRepository::class);
|
|
$userPasswordPolicyService = app(\MintyPHP\Service\User\UserPasswordPolicyService::class);
|
|
$userCustomFieldValueService = app(UserCustomFieldValueService::class);
|
|
$authorizationService = app(\MintyPHP\Service\Access\AuthorizationService::class);
|
|
$rolePermissionRepository = app(\MintyPHP\Repository\Access\RolePermissionRepository::class);
|
|
$authService = app(\MintyPHP\Service\Auth\AuthService::class);
|
|
$apiTokenService = app(\MintyPHP\Service\Auth\ApiTokenService::class);
|
|
$passwordResetRepository = app(PasswordResetRepository::class);
|
|
$rememberTokenRepository = app(RememberTokenRepository::class);
|
|
$tenantService = app(\MintyPHP\Service\Tenant\TenantService::class);
|
|
$roleService = app(\MintyPHP\Service\Access\RoleService::class);
|
|
$departmentService = app(\MintyPHP\Service\Org\DepartmentService::class);
|
|
$passwordMinLength = $userPasswordPolicyService->minLength();
|
|
$passwordHints = $userPasswordPolicyService->hints();
|
|
|
|
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
|
$uuid = trim((string) ($id ?? ''));
|
|
$editTarget = requestPathWithReturnTarget("admin/users/edit/{$uuid}", $returnTarget);
|
|
|
|
// Resolve the user BEFORE the aggregator so the authorize-context can carry
|
|
// the real target_user_id (matches today's order: lookup -> CONTEXT authorize
|
|
// -> Router::redirect on forbidden). The aggregator receives a trivial finder
|
|
// that returns the already-loaded model so its not-found branch fires only
|
|
// when findByUuid() returned null.
|
|
$user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null;
|
|
$userId = (int) ($user['id'] ?? 0);
|
|
|
|
$context = actionEditContext([
|
|
'finder' => static fn (string $_id): mixed => $user,
|
|
'rawId' => $uuid,
|
|
'notFoundFlashKey' => 'User not found',
|
|
'notFoundRedirectPath' => $closeTarget,
|
|
'notFoundFlashScopeKey' => 'user_not_found',
|
|
'abilityKey' => UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT,
|
|
'context' => [
|
|
'actor_user_id' => $currentUserId,
|
|
'target_user_id' => $userId,
|
|
],
|
|
'viewAuthFlags' => [
|
|
'can_view_users',
|
|
'can_view_page',
|
|
'can_edit_user',
|
|
'can_edit_assignments',
|
|
'can_manage_tenants',
|
|
'can_edit_custom_field_values',
|
|
'can_manage_api_tokens',
|
|
'can_view_user_meta',
|
|
'can_view_user_audit',
|
|
'can_access_pdf',
|
|
'can_delete_user',
|
|
'can_view_security_artifacts',
|
|
'can_view_permissions_table',
|
|
'is_own_account',
|
|
],
|
|
'forbiddenStrategy' => 'redirect',
|
|
'tenantScopeFlagKey' => 'can_manage_tenants',
|
|
]);
|
|
$user = is_array($context['model']) ? $context['model'] : $user;
|
|
$capabilities = $context['capabilities'];
|
|
$tenantScope = $context['tenantScope'];
|
|
$viewAuth = $context['viewAuth'];
|
|
|
|
$canViewUsers = (bool) ($capabilities['can_view_users'] ?? false);
|
|
$canViewPage = (bool) ($capabilities['can_view_page'] ?? false);
|
|
$canEditUser = (bool) ($capabilities['can_edit_user'] ?? false);
|
|
$canEditAssignments = (bool) ($capabilities['can_edit_assignments'] ?? false);
|
|
$canManageTenants = (bool) ($capabilities['can_manage_tenants'] ?? false);
|
|
$canEditCustomFieldValues = (bool) ($capabilities['can_edit_custom_field_values'] ?? false);
|
|
$canManageApiTokens = (bool) ($capabilities['can_manage_api_tokens'] ?? false);
|
|
$canViewUserMeta = (bool) ($capabilities['can_view_user_meta'] ?? false);
|
|
$canViewUserAudit = (bool) ($capabilities['can_view_user_audit'] ?? false);
|
|
$canAccessPdf = (bool) ($capabilities['can_access_pdf'] ?? false);
|
|
$canDeleteUser = (bool) ($capabilities['can_delete_user'] ?? false);
|
|
$canViewSecurityArtifacts = (bool) ($capabilities['can_view_security_artifacts'] ?? false);
|
|
$canViewPermissionsTable = (bool) ($capabilities['can_view_permissions_table'] ?? false);
|
|
$isOwnAccount = (bool) ($capabilities['is_own_account'] ?? false);
|
|
// $canManageAllTenants = Tupel-Diskriminator (true wenn Aggregator scope='all'
|
|
// liefert via tenantScopeFlagKey='can_manage_tenants'-Override). $canManageTenants
|
|
// bleibt als Capability-Flag erhalten — semantisch aequivalent, aber unterschiedliche
|
|
// Quelle. Filter-Branches unten nutzen $canManageAllTenants als Tupel-Diskriminator.
|
|
$canManageAllTenants = $tenantScope['scope'] === 'all';
|
|
|
|
if ($canViewUserMeta || $canViewUserAudit) {
|
|
app(\MintyPHP\Service\Audit\AuditMetadataEnricherInterface::class)->enrich($user, ['created_by', 'modified_by', 'active_changed_by']);
|
|
}
|
|
|
|
$errorBag = formErrors();
|
|
$errors = [];
|
|
$form = $user;
|
|
$scopeGateway = app(\MintyPHP\Service\Tenant\TenantScopeService::class);
|
|
$strictTenantScope = $scopeGateway->isStrict();
|
|
|
|
$tenants = $tenantService->list();
|
|
if (!$canManageAllTenants) {
|
|
if (count($tenantScope['ids']) > 0) {
|
|
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($tenantScope): bool {
|
|
$tenantId = (int) ($tenant['id'] ?? 0);
|
|
return $tenantId > 0 && in_array($tenantId, $tenantScope['ids'], true);
|
|
}));
|
|
} elseif ($strictTenantScope) {
|
|
$tenants = [];
|
|
}
|
|
}
|
|
|
|
$selectedTenantIds = $userTenantRepository->listTenantIdsByUserId($userId);
|
|
if (!$canManageAllTenants && count($tenantScope['ids']) > 0) {
|
|
$selectedTenantIds = array_values(array_intersect($selectedTenantIds, $tenantScope['ids']));
|
|
}
|
|
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
|
|
if (!$primaryTenantId && count($selectedTenantIds) === 1) {
|
|
$primaryTenantId = (int) $selectedTenantIds[0];
|
|
$user['primary_tenant_id'] = $primaryTenantId;
|
|
}
|
|
|
|
$roles = $roleService->listActive();
|
|
$assignableRoleService = app(\MintyPHP\Service\Access\AssignableRoleService::class);
|
|
$assignableRoleIds = $assignableRoleService->listAssignableRoleIdsForActor($currentUserId);
|
|
$assignableRoleIdMap = array_fill_keys($assignableRoleIds, true);
|
|
$roles = array_values(array_filter($roles, static fn (array $r): bool => isset($assignableRoleIdMap[(int) ($r['id'] ?? 0)])));
|
|
$selectedRoleIds = $userRoleRepository->listRoleIdsByUserId($userId);
|
|
$visibleSelectedRoleIds = array_values(array_intersect($selectedRoleIds, $assignableRoleIds));
|
|
$permissionRows = $canViewPermissionsTable
|
|
? $rolePermissionRepository->listPermissionsWithRolesByRoleIds($selectedRoleIds)
|
|
: [];
|
|
$selectedDepartmentIds = $userDepartmentRepository->listDepartmentIdsByUserId($userId);
|
|
$departmentOptionsByTenant = $departmentService->groupActiveByTenantIds($selectedTenantIds);
|
|
|
|
$customFieldDefinitionsByTenant = $userCustomFieldValueService->buildDefinitionsByTenant($selectedTenantIds);
|
|
$customFieldDefinitionIds = [];
|
|
foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) {
|
|
foreach ($tenantDefinitions as $definition) {
|
|
$definitionId = (int) ($definition['id'] ?? 0);
|
|
if ($definitionId > 0) {
|
|
$customFieldDefinitionIds[] = $definitionId;
|
|
}
|
|
}
|
|
}
|
|
$customFieldValueMap = $userCustomFieldValueService->buildUserValueMap($userId, $customFieldDefinitionIds);
|
|
$customFieldPostedValues = [
|
|
'custom_field_values' => [],
|
|
'custom_field_values_multi' => [],
|
|
];
|
|
|
|
$passwordResets = [];
|
|
$rememberTokens = [];
|
|
$apiTokens = [];
|
|
if ($canViewSecurityArtifacts) {
|
|
$passwordResets = $passwordResetRepository->listByUserId($userId, 25);
|
|
$rememberTokens = $rememberTokenRepository->listByUserId($userId, 25);
|
|
$apiTokens = $apiTokenService->listForUser($userId);
|
|
}
|
|
$canManageApiTokens = $canManageApiTokens && $canViewSecurityArtifacts;
|
|
$showApiTokens = $canViewSecurityArtifacts;
|
|
|
|
if ($request->isMethod('POST') && !actionRequireCsrf($editTarget, $editTarget, 'csrf_expired')) {
|
|
return;
|
|
}
|
|
|
|
if ($request->isMethod('POST')) {
|
|
$post = $request->bodyAll();
|
|
$submitDecision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT, [
|
|
'actor_user_id' => $currentUserId,
|
|
'target_user_id' => $userId,
|
|
'input' => $post,
|
|
]);
|
|
if (!$submitDecision->isAllowed()) {
|
|
Router::redirect('error/forbidden');
|
|
return;
|
|
}
|
|
|
|
$submitCapabilities = $submitDecision->attribute('capabilities', []);
|
|
if (!is_array($submitCapabilities) || !isset($submitCapabilities['can_edit_user']) || !$submitCapabilities['can_edit_user']) {
|
|
Router::redirect('error/forbidden');
|
|
return;
|
|
}
|
|
|
|
$canEditUser = (bool) $submitCapabilities['can_edit_user'];
|
|
$canEditAssignments = (bool) ($submitCapabilities['can_edit_assignments'] ?? $canEditAssignments);
|
|
$canEditCustomFieldValues = (bool) ($submitCapabilities['can_edit_custom_field_values'] ?? $canEditCustomFieldValues);
|
|
|
|
$result = $userAccountService->updateFromAdmin($userId, $post, $currentUserId);
|
|
$form = $result['form'] ?? $form;
|
|
$errorBag->merge($result['errors'] ?? []);
|
|
$tenantIds = $userAssignmentService->normalizeIdInput($post['tenant_ids'] ?? []);
|
|
$existingTenantIds = $userTenantRepository->listTenantIdsByUserId($userId);
|
|
$tenantIdsForSync = $tenantIds;
|
|
|
|
if ($canEditAssignments && !$canManageAllTenants && count($tenantScope['ids']) > 0) {
|
|
$tenantIds = array_values(array_intersect($tenantIds, $tenantScope['ids']));
|
|
$tenantIdsForSync = $scopeGateway->mergeTenantIdsPreservingOutOfScope(
|
|
$tenantIds,
|
|
$existingTenantIds,
|
|
$tenantScope['ids']
|
|
);
|
|
} elseif ($canEditAssignments && !$canManageAllTenants && $strictTenantScope) {
|
|
$tenantIds = [];
|
|
$tenantIdsForSync = $existingTenantIds;
|
|
}
|
|
|
|
if (!$canEditAssignments) {
|
|
$tenantIds = $userTenantRepository->listTenantIdsByUserId($userId);
|
|
if (!$canManageAllTenants && count($tenantScope['ids']) > 0) {
|
|
$tenantIds = array_values(array_intersect($tenantIds, $tenantScope['ids']));
|
|
}
|
|
$tenantIdsForSync = $tenantIds;
|
|
}
|
|
|
|
$selectedTenantIds = $tenantIds;
|
|
$primaryTenantId = (int) ($post['primary_tenant_id'] ?? 0);
|
|
$selectedRoleIds = $userAssignmentService->normalizeIdInput($post['role_ids'] ?? []);
|
|
$selectedDepartmentIds = $userAssignmentService->normalizeIdInput($post['department_ids'] ?? []);
|
|
$departmentOptionsByTenant = $departmentService->groupActiveByTenantIds($selectedTenantIds);
|
|
$customFieldDefinitionsByTenant = $userCustomFieldValueService->buildDefinitionsByTenant($selectedTenantIds);
|
|
$customFieldDefinitionIds = [];
|
|
foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) {
|
|
foreach ($tenantDefinitions as $definition) {
|
|
$definitionId = (int) ($definition['id'] ?? 0);
|
|
if ($definitionId > 0) {
|
|
$customFieldDefinitionIds[] = $definitionId;
|
|
}
|
|
}
|
|
}
|
|
$customFieldValueMap = $userCustomFieldValueService->buildUserValueMap($userId, $customFieldDefinitionIds);
|
|
$customFieldPostedValues = [
|
|
'custom_field_values' => is_array($post['custom_field_values'] ?? null) ? $post['custom_field_values'] : [],
|
|
'custom_field_values_multi' => is_array($post['custom_field_values_multi'] ?? null) ? $post['custom_field_values_multi'] : [],
|
|
];
|
|
|
|
if ($result['ok'] ?? false) {
|
|
if ($canEditAssignments) {
|
|
$assignmentsBefore = $userAssignmentService->buildAssignmentsForUser($userId);
|
|
$assignmentsSynced = $userAssignmentService->syncAllAssignments(
|
|
$userId,
|
|
$tenantIdsForSync,
|
|
$selectedRoleIds,
|
|
$selectedDepartmentIds,
|
|
$currentUserId
|
|
);
|
|
if (!$assignmentsSynced) {
|
|
$errorBag->addGlobal(t('User assignments can not be updated'));
|
|
} else {
|
|
$assignmentsAfter = $userAssignmentService->buildAssignmentsForUser($userId);
|
|
$userAccountService->dispatchAssignmentChangedIfNeeded(
|
|
$userId,
|
|
$assignmentsBefore,
|
|
$assignmentsAfter,
|
|
$currentUserId
|
|
);
|
|
}
|
|
|
|
if ($currentUserId === $userId && !$errorBag->hasAny()) {
|
|
$authService->loadTenantDataIntoSession($userId);
|
|
}
|
|
}
|
|
|
|
$customFieldSyncResult = $userCustomFieldValueService->syncForUser(
|
|
$userId,
|
|
$selectedTenantIds,
|
|
$post,
|
|
$canEditCustomFieldValues
|
|
);
|
|
if (!($customFieldSyncResult['ok'] ?? false)) {
|
|
$errorBag->merge($customFieldSyncResult['errors'] ?? []);
|
|
$customFieldValueMap = $userCustomFieldValueService->buildUserValueMap($userId, $customFieldDefinitionIds);
|
|
}
|
|
if ($currentUserId === $userId && isset($form['theme'])) {
|
|
$themes = appThemes();
|
|
$themeValue = strtolower(trim((string) ($form['theme'] ?? '')));
|
|
$userSession = $sessionStore->get('user', []);
|
|
if (is_array($userSession)) {
|
|
$userSession['theme'] = isset($themes[$themeValue]) ? $themeValue : appDefaultTheme();
|
|
$sessionStore->set('user', $userSession);
|
|
}
|
|
}
|
|
if ($currentUserId === $userId && isset($form['locale'])) {
|
|
$userSession = $sessionStore->get('user', []);
|
|
if (is_array($userSession)) {
|
|
$userSession['locale'] = $form['locale'];
|
|
$sessionStore->set('user', $userSession);
|
|
}
|
|
I18n::$locale = $form['locale'];
|
|
}
|
|
if (!$errorBag->hasAny()) {
|
|
$action = (string) ($post['action'] ?? 'save');
|
|
if ($action === 'save_close') {
|
|
Flash::success(t('User updated'), $closeTarget, 'user_updated');
|
|
Router::redirect($closeTarget);
|
|
} else {
|
|
Flash::success(t('User updated'), $editTarget, 'user_updated');
|
|
Router::redirect($editTarget);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$validationSummaryErrors = $errorBag->toArray();
|
|
$errors = $errorBag->toFlatList();
|
|
$titleText = $isOwnAccount ? t('My account') : ($canEditUser ? t('Edit user') : t('View user'));
|
|
Buffer::set('title', $titleText);
|
|
if (!($isOwnAccount && !$canViewUsers)) {
|
|
$breadcrumbs = [
|
|
['label' => t('Home'), 'path' => 'admin'],
|
|
['label' => t('Users'), 'path' => 'admin/users'],
|
|
['label' => $titleText],
|
|
];
|
|
}
|