From e339b791066fc0860949e339020d6af98e1c12e7 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Sun, 6 Sep 2026 13:16:47 +0900 Subject: [PATCH] Add "Export issues" and "Export time entries" permissions --- app/controllers/application_controller.rb | 23 ++++++ app/controllers/gantts_controller.rb | 6 ++ app/controllers/issues_controller.rb | 10 +++ app/controllers/reports_controller.rb | 6 ++ app/controllers/timelog_controller.rb | 10 +++ app/views/gantts/_chart.html.erb | 2 + app/views/issues/index.html.erb | 5 ++ app/views/issues/show.html.erb | 2 +- app/views/reports/_details.html.erb | 2 + app/views/timelog/index.html.erb | 5 +- app/views/timelog/report.html.erb | 4 +- config/locales/en.yml | 2 + ...ues_and_export_time_entries_permissions.rb | 14 ++++ lib/redmine/default_data/loader.rb | 6 ++ lib/redmine/preparation.rb | 2 + test/fixtures/roles.yml | 10 +++ test/functional/gantts_controller_test.rb | 19 +++++ test/functional/issues_controller_test.rb | 74 +++++++++++++++++++ test/functional/reports_controller_test.rb | 32 ++++++++ test/functional/timelog_controller_test.rb | 49 ++++++++++++ 20 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260906000000_add_export_issues_and_export_time_entries_permissions.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6dc17d26b..9273d5e36 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -565,6 +565,29 @@ class ApplicationController < ActionController::Base private :valid_back_url? helper_method :valid_back_url? + # Returns true if the current user has the given export permission on + # the projects of all the records in scope (issues or time entries) + def export_allowed?(permission, scope) + user = User.current + return true if user.admin? + + if @project + # Deny if the user does not have the permission on @project itself + return false unless user.allowed_to?(permission, @project) + # @project has no subprojects, so no further check is needed + return true if @project.leaf? + else + # Cross-project view: deny if none of the user's roles has the permission + return false unless user.allowed_to?(permission, nil, :global => true) + end + + # Deny if the scope contains records of a project where + # the user does not have the permission + !scope.where.not(:project_id => Project.allowed_to(user, permission).select(:id)).exists? + end + private :export_allowed? + helper_method :export_allowed? + # Redirects to the request referer if present, redirects to args or call block otherwise. def redirect_to_referer_or(*args, &block) if referer = request.headers["Referer"] diff --git a/app/controllers/gantts_controller.rb b/app/controllers/gantts_controller.rb index 66c93b34a..433c26af8 100644 --- a/app/controllers/gantts_controller.rb +++ b/app/controllers/gantts_controller.rb @@ -37,6 +37,12 @@ class GanttsController < ApplicationController @query.group_by = nil @gantt.query = @query if @query.valid? + @export_allowed = export_allowed?(:export_issues, @query.valid? ? @query.base_scope : Issue.none) + if (request.format.pdf? || request.format.png?) && !@export_allowed + deny_access + return + end + basename = (@project ? "#{@project.identifier}-" : '') + 'gantt' respond_to do |format| diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb index 9ae4d4393..8734a0035 100644 --- a/app/controllers/issues_controller.rb +++ b/app/controllers/issues_controller.rb @@ -48,6 +48,11 @@ class IssuesController < ApplicationController retrieve_query(IssueQuery, use_session) if @query.valid? + if (request.format.csv? || request.format.pdf?) && !export_allowed?(:export_issues, @query.base_scope) + deny_access + return + end + respond_to do |format| format.html do @issue_count = @query.issue_count @@ -95,6 +100,11 @@ class IssuesController < ApplicationController end def show + if request.format.pdf? && !User.current.allowed_to?(:export_issues, @project) + deny_access + return + end + if !api_request? || include_in_api_response?('journals') @journals = @issue.visible_journals_with_index @journals.reverse! if User.current.wants_comments_in_reverse_order? diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 94a875540..163b04090 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -44,6 +44,12 @@ class ReportsController < ApplicationController def issue_report_details with_subprojects = Setting.display_subprojects_issues? + @export_allowed = export_allowed?(:export_issues, Issue.visible(User.current, :project => @project, :with_subprojects => with_subprojects)) + if request.format.csv? && !@export_allowed + deny_access + return + end + case params[:detail] when "tracker" @field = "tracker_id" diff --git a/app/controllers/timelog_controller.rb b/app/controllers/timelog_controller.rb index 6dd3edf18..16a844efa 100644 --- a/app/controllers/timelog_controller.rb +++ b/app/controllers/timelog_controller.rb @@ -43,6 +43,11 @@ class TimelogController < ApplicationController def index retrieve_time_entry_query + if request.format.csv? && !export_allowed?(:export_time_entries, @query.base_scope) + deny_access + return + end + scope = time_entry_scope. preload(:issue => [:project, :tracker, :status, :assigned_to, :priority]). preload(:project, :user) @@ -74,6 +79,11 @@ class TimelogController < ApplicationController def report retrieve_time_entry_query + if request.format.csv? && !export_allowed?(:export_time_entries, @query.base_scope) + deny_access + return + end + scope = time_entry_scope @report = Redmine::Helpers::TimeReport.new(@project, params[:criteria], params[:columns], scope) diff --git a/app/views/gantts/_chart.html.erb b/app/views/gantts/_chart.html.erb index 10667b656..52df9ea0a 100644 --- a/app/views/gantts/_chart.html.erb +++ b/app/views/gantts/_chart.html.erb @@ -249,7 +249,9 @@ +<% if @export_allowed %> <% other_formats_links do |f| %> <%= f.link_to_with_query_parameters 'PDF', gantt.params %> <%= f.link_to_with_query_parameters('PNG', gantt.params) if gantt.respond_to?('to_image') %> <% end %> +<% end %> diff --git a/app/views/issues/index.html.erb b/app/views/issues/index.html.erb index 70f0b740a..aa8829164 100644 --- a/app/views/issues/index.html.erb +++ b/app/views/issues/index.html.erb @@ -34,12 +34,16 @@ <%= pagination_links_full @issue_pages, @issue_count %> <% end %> +<% export_allowed = export_allowed?(:export_issues, @query.base_scope) %> <% other_formats_links do |f| %> + <% if export_allowed %> <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '350px'); return false;" %> <%= f.link_to_with_query_parameters 'PDF' %> + <% end %> <%= f.link_to_with_query_parameters 'Atom', :key => User.current.atom_key %> <% end %> +<% if export_allowed %> +<% end %> <% end %> <%= call_hook(:view_issues_index_bottom, { :issues => @issues, :project => @project, :query => @query }) %> diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 13d35cc82..eaeecf44b 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -155,7 +155,7 @@ end %> <%= render partial: 'action_menu_edit' unless User.current.wants_comments_in_reverse_order? %> <% other_formats_links do |f| %> - <%= f.link_to 'PDF' %> + <%= f.link_to 'PDF' if User.current.allowed_to?(:export_issues, @project) %> <%= f.link_to 'Atom', :url => {:key => User.current.atom_key} %> <% end %> diff --git a/app/views/reports/_details.html.erb b/app/views/reports/_details.html.erb index 959d589fa..566177a05 100644 --- a/app/views/reports/_details.html.erb +++ b/app/views/reports/_details.html.erb @@ -36,6 +36,7 @@ +<% if @export_allowed %> <% other_formats_links do |f| %> <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %> <% end %> @@ -50,6 +51,7 @@

<% end %> +<% end %>