$this->authorizeAdminUsersView($context), self::ABILITY_ADMIN_USERS_CREATE => $this->authorizeAdminUsersCreate($context), self::ABILITY_ADMIN_USERS_EDIT_CONTEXT => $this->authorizeAdminUsersEditContext($context), self::ABILITY_ADMIN_USERS_EDIT_SUBMIT => $this->authorizeAdminUsersEditSubmit($context), self::ABILITY_ADMIN_USERS_ACTIVATE, self::ABILITY_ADMIN_USERS_DEACTIVATE => $this->authorizeAdminUserStatusUpdate($context), self::ABILITY_ADMIN_USERS_DELETE => $this->authorizeAdminUserDelete($context), self::ABILITY_ADMIN_USERS_BULK => $this->authorizeAdminUsersBulk($context), self::ABILITY_ADMIN_USERS_ACCESS_PDF => $this->authorizeAdminUserAccessPdf($context), self::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK => $this->authorizeAdminUsersAccessPdfBulk($context), self::ABILITY_ADMIN_USERS_AVATAR_UPLOAD, self::ABILITY_ADMIN_USERS_AVATAR_DELETE => $this->authorizeAdminUsersAvatarMutate($context), self::ABILITY_ADMIN_USERS_AVATAR_VIEW => $this->authorizeAdminUsersAvatarView($context), self::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE => $this->authorizeAdminUsersApiTokensManage($context), self::ABILITY_ADMIN_USERS_SEND_ACCESS, self::ABILITY_ADMIN_USERS_FORGET_TOKENS => $this->authorizeAdminUserSelfOrUpdate($context), self::ABILITY_API_USERS_INDEX_GET => $this->authorizeApiUsersIndexGet($context), self::ABILITY_API_USERS_INDEX_POST => $this->authorizeApiUsersIndexPost($context), self::ABILITY_API_USERS_SHOW_GET => $this->authorizeApiUsersShow($context, PermissionService::USERS_VIEW), self::ABILITY_API_USERS_SHOW_UPDATE => $this->authorizeApiUsersUpdate($context), self::ABILITY_API_USERS_SHOW_DELETE => $this->authorizeApiUsersShow($context, PermissionService::USERS_DELETE), default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), }; } private function authorizeAdminUsersView(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::USERS_VIEW, $context, false); } private function authorizeAdminUsersCreate(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::USERS_CREATE, $context, false); } // Returns the full capabilities map as attributes — the submit handler re-uses it to avoid recalculating. private function authorizeAdminUsersEditContext(array $context): AuthorizationDecision { return $this->buildEditContextDecision($context); } private function authorizeAdminUsersEditSubmit(array $context): AuthorizationDecision { $contextDecision = $this->buildEditContextDecision($context); if (!$contextDecision->isAllowed()) { return $contextDecision; } $capabilities = $this->capabilitiesFromDecision($contextDecision); if (!($capabilities['can_edit_user'] ?? false)) { return AuthorizationDecision::deny(403, 'forbidden'); } return AuthorizationDecision::allow(['capabilities' => $capabilities]); } private function authorizeAdminUserStatusUpdate(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::USERS_UPDATE, $context); } private function authorizeAdminUserDelete(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::USERS_DELETE, $context); } private function authorizeAdminUserAccessPdf(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::USERS_ACCESS_PDF, $context); } private function authorizeAdminUsersAccessPdfBulk(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::USERS_ACCESS_PDF, $context, false); } private function authorizeAdminUsersApiTokensManage(array $context): AuthorizationDecision { return $this->authorizeAdminUserWithPermission(PermissionService::API_TOKENS_MANAGE, $context); } private function authorizeAdminUsersAvatarMutate(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); $targetUserId = (int) ($context['target_user_id'] ?? 0); if ($actorUserId <= 0 || $targetUserId <= 0) { return AuthorizationDecision::deny(403, 'forbidden'); } $isOwnAccount = $actorUserId === $targetUserId; if (!$isOwnAccount && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { return AuthorizationDecision::deny(403, 'permission_denied'); } $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); if (!$canEditUser) { return AuthorizationDecision::deny(403, 'forbidden'); } return AuthorizationDecision::allow(); } private function authorizeAdminUsersAvatarView(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); $targetUserId = (int) ($context['target_user_id'] ?? 0); if ($actorUserId <= 0 || $targetUserId <= 0) { return AuthorizationDecision::deny(403, 'forbidden'); } if ($actorUserId === $targetUserId) { return AuthorizationDecision::allow(); } if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { return AuthorizationDecision::deny(403, 'permission_denied'); } $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW) && !$this->hasPermission($actorUserId, 'address_book.view') // Registered by addressbook module when active && !$canEditUser) { return AuthorizationDecision::deny(403, 'forbidden'); } return AuthorizationDecision::allow(); } private function authorizeAdminUserSelfOrUpdate(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); $targetUserId = (int) ($context['target_user_id'] ?? 0); if ($targetUserId <= 0) { return AuthorizationDecision::deny(403, 'forbidden'); } if ($this->canEditUserForTarget($actorUserId, $targetUserId)) { if ($targetUserId !== $actorUserId && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { return AuthorizationDecision::deny(403, 'permission_denied'); } return AuthorizationDecision::allow(); } return AuthorizationDecision::deny(403, 'forbidden'); } private function authorizeAdminUsersBulk(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); $action = strtolower(trim((string) ($context['bulk_action'] ?? ''))); $permission = match ($action) { 'delete' => PermissionService::USERS_DELETE, 'activate', 'deactivate', 'send-access' => PermissionService::USERS_UPDATE, default => null, }; if ($permission === null) { return AuthorizationDecision::deny(400, 'invalid_action'); } if (!$this->hasPermission($actorUserId, $permission)) { return AuthorizationDecision::deny(403, 'forbidden'); } return AuthorizationDecision::allow(); } private function authorizeApiUsersIndexGet(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)) { return AuthorizationDecision::deny(403, 'forbidden'); } return AuthorizationDecision::allow(); } private function authorizeApiUsersIndexPost(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); if (!$this->hasPermission($actorUserId, PermissionService::USERS_CREATE)) { return AuthorizationDecision::deny(403, 'forbidden'); } $input = is_array($context['input'] ?? null) ? $context['input'] : []; $scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0); if ($scopedTenantId <= 0) { return AuthorizationDecision::allow(['input' => $input]); } if ($this->containsOutOfScopeTenantId($input['tenant_ids'] ?? [], $scopedTenantId)) { return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); } $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) { return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); } $input['tenant_ids'] = [$scopedTenantId]; $input['primary_tenant_id'] = $scopedTenantId; return AuthorizationDecision::allow(['input' => $input]); } private function authorizeApiUsersUpdate(array $context): AuthorizationDecision { $decision = $this->authorizeApiUsersShow($context, PermissionService::USERS_UPDATE); if (!$decision->isAllowed()) { return $decision; } $input = is_array($context['input'] ?? null) ? $context['input'] : []; $scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0); if ($scopedTenantId <= 0) { return AuthorizationDecision::allow(['input' => $input]); } if (array_key_exists('tenant_ids', $input) && $this->containsOutOfScopeTenantId($input['tenant_ids'], $scopedTenantId)) { return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); } if (array_key_exists('primary_tenant_id', $input)) { $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) { return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); } } return AuthorizationDecision::allow(['input' => $input]); } private function authorizeApiUsersShow(array $context, string $permissionKey): AuthorizationDecision { $actorUserId = $this->actorUserId($context); if (!$this->hasPermission($actorUserId, $permissionKey)) { return AuthorizationDecision::deny(403, 'forbidden'); } $targetUserId = (int) ($context['target_user_id'] ?? 0); if ($targetUserId <= 0) { return AuthorizationDecision::allow(); } // 404 instead of 403 — don't reveal that the resource exists outside the actor's scope. if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { return AuthorizationDecision::deny(404, 'not_found'); } $scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0); if ($scopedTenantId > 0 && !$this->scopeGateway->resourceBelongsToTenant('users', $targetUserId, $scopedTenantId)) { return AuthorizationDecision::deny(404, 'not_found'); } return AuthorizationDecision::allow(); } private function containsOutOfScopeTenantId(mixed $tenantIdsRaw, int $scopedTenantId): bool { $tenantIds = $this->normalizeTenantIds($tenantIdsRaw); if (!$tenantIds) { return false; } return array_diff($tenantIds, [$scopedTenantId]) !== []; } private function normalizeTenantIds(mixed $tenantIdsRaw): array { if (!is_array($tenantIdsRaw)) { $tenantIdsRaw = [$tenantIdsRaw]; } $tenantIds = []; foreach ($tenantIdsRaw as $value) { $tenantId = (int) $value; if ($tenantId > 0) { $tenantIds[$tenantId] = $tenantId; } } return array_values($tenantIds); } private function canEditUserForTarget(int $actorUserId, int $targetUserId): bool { if ($actorUserId <= 0 || $targetUserId <= 0) { return false; } $canUpdateUser = $this->hasPermission($actorUserId, PermissionService::USERS_UPDATE); if ($targetUserId === $actorUserId) { return $canUpdateUser || $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE); } return $canUpdateUser; } private function buildEditContextDecision(array $context): AuthorizationDecision { $actorUserId = $this->actorUserId($context); $targetUserId = (int) ($context['target_user_id'] ?? 0); if ($actorUserId <= 0 || $targetUserId <= 0) { return AuthorizationDecision::deny(403, 'forbidden'); } $isOwnAccount = $targetUserId === $actorUserId; if (!$isOwnAccount && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { return AuthorizationDecision::deny(403, 'permission_denied'); } $canViewUsers = $this->hasPermission($actorUserId, PermissionService::USERS_VIEW); $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); $canViewPage = $canViewUsers || $canEditUser; if (!$canViewPage) { return AuthorizationDecision::deny(403, 'forbidden'); } $canManageTenants = $this->hasPermission($actorUserId, PermissionService::TENANTS_UPDATE); $allowedTenantIds = $canManageTenants ? null : $this->scopeGateway->getUserTenantIds($actorUserId); $canViewUserAudit = $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_AUDIT); $capabilities = [ 'can_view_page' => $canViewPage, 'can_view_users' => $canViewUsers, 'can_edit_user' => $canEditUser, 'can_edit_assignments' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS), 'can_manage_tenants' => $canManageTenants, 'can_edit_custom_field_values' => $canEditUser && ( $this->hasPermission($actorUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES) || ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE)) ), 'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE), 'can_view_address_book' => $this->hasPermission($actorUserId, 'address_book.view'), // Registered by addressbook module when active 'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META), 'can_view_user_audit' => $canViewUserAudit, 'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF), 'can_delete_user' => !$isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_DELETE), 'can_view_security_artifacts' => $isOwnAccount || $canViewUserAudit, 'can_view_permissions_table' => $this->hasPermission($actorUserId, PermissionService::PERMISSIONS_VIEW), 'is_own_account' => $isOwnAccount, 'allowed_tenant_ids' => is_array($allowedTenantIds) ? array_values(array_unique(array_map('intval', $allowedTenantIds))) : null, ]; return AuthorizationDecision::allow(['capabilities' => $capabilities]); } private function authorizeAdminUserWithPermission( string $permissionKey, array $context, bool $checkTenantScope = true ): AuthorizationDecision { $actorUserId = $this->actorUserId($context); if (!$this->hasPermission($actorUserId, $permissionKey)) { return AuthorizationDecision::deny(403, 'forbidden'); } if (!$checkTenantScope) { return AuthorizationDecision::allow(); } $targetUserId = (int) ($context['target_user_id'] ?? 0); if ($targetUserId > 0 && $targetUserId !== $actorUserId && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { return AuthorizationDecision::deny(403, 'permission_denied'); } return AuthorizationDecision::allow(); } }