Skip to content

Commit

Permalink
Merge pull request #15906 from opf/feature/50844-new-permissions-for-…
Browse files Browse the repository at this point in the history
…project-attributes-on-project-level

[#50844] New permissions for project attributes on project level
  • Loading branch information
dombesz authored Jul 31, 2024
2 parents 386ba39 + fbbdc75 commit 7013882
Show file tree
Hide file tree
Showing 62 changed files with 1,961 additions and 464 deletions.
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Naming/VariableNumber:
AllowedPatterns:
- '\w_20\d\d' # allow dates like christmas_2022 or date_2034_04_12
- '\w\d++(_\d++)+' # allow hierarchical data like child1_2_5 (second + in regex is possessive qualifier)

- 'custom_field_\d+' # allow custom field method names to be called with send :custom_field_1001
# There are valid cases in which to use methods like:
# * update_all
# * touch_all
Expand Down
2 changes: 1 addition & 1 deletion app/components/projects/row_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def more_menu_delete_item
end

def user_can_view_project?
User.current.allowed_in_project?(:view_project, project)
User.current.allowed_in_project?(:view_project_attributes, project)
end

def custom_field_column?(column)
Expand Down
13 changes: 1 addition & 12 deletions app/contracts/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,7 @@ def collect_writable_attributes
end

def collect_available_custom_field_attributes
if model.is_a?(Project)
# required because project custom fields are now activated on a per-project basis
#
# if we wouldn't query available_custom field on a global level here,
# implicitly enabling project custom fields through this contract would fail
# as the disabled custom fields would be treated as not-writable
#
# relevant especially for the project API
model.all_available_custom_fields.map(&:attribute_name)
else
model.available_custom_fields.map(&:attribute_name)
end
model.available_custom_fields.map(&:attribute_name)
end

def reduce_writable_attributes(attributes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ def not_required
def visbile_to_user
# "invisible" custom fields can only be seen and edited by admins
# using visible scope to check if the custom field is actually visible to the user
return if model.project_custom_field.nil? || ProjectCustomField.visible.pluck(:id).include?(model.project_custom_field.id)
return if model.project_custom_field.nil? ||
ProjectCustomField.visible(user).pluck(:id).include?(model.project_custom_field.id)

errors.add :custom_field_id, :invalid
end
Expand Down
14 changes: 14 additions & 0 deletions app/contracts/projects/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ def assignable_status_codes
Project.status_codes.keys
end

protected

def collect_available_custom_field_attributes
# required because project custom fields are now activated on a per-project basis
#
# if we wouldn't query available_custom field on a global level here,
# implicitly enabling project custom fields through this contract would fail
# as the disabled custom fields would be treated as not-writable
#
# relevant especially for the project API

model.all_available_custom_fields.map(&:attribute_name)
end

private

def validate_parent_assignable
Expand Down
27 changes: 27 additions & 0 deletions app/contracts/projects/create_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,35 @@ class CreateContract < BaseContract
# so allowing writing here would be useless.
allow_writable_timestamps :created_at

def writable_attributes
if allowed_to_write_custom_fields?
super
else
without_custom_fields(super)
end
end

protected

def collect_available_custom_field_attributes
model.all_visible_custom_fields.map(&:attribute_name)
end

private

def allowed_to_write_custom_fields?
# Writable attributes are already restricted based on their visibility in the
# ProjectCustomField.visible scope. Here it is enough to check whether the user
# has permission to copy_projects or edit_project_attributes in any project.
user.admin? ||
user.allowed_globally?(:add_project) ||
(model.parent && user.allowed_in_project?(:add_subprojects, model.parent)) ||
user.allowed_in_any_project?(:copy_projects) ||
user.allowed_in_any_project?(:edit_project_attributes)
end

def without_custom_fields(changes) = changes.grep_v(/^custom_field_/)

def validate_user_allowed_to_manage
unless user.allowed_globally?(:add_project) ||
(model.parent && user.allowed_in_project?(:add_subprojects, model.parent))
Expand Down
35 changes: 35 additions & 0 deletions app/contracts/projects/update_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,46 @@

module Projects
class UpdateContract < BaseContract
def writable_attributes
if allow_project_attributes_only
with_custom_fields_only(super)
elsif allow_edit_attributes_only
without_custom_fields(super)
elsif allow_all_attributes
super
else
[]
end
end

private

def project_attributes_only = options[:project_attributes_only].present?

def edit_project = user.allowed_in_project?(:edit_project, model)

def edit_project_attributes = user.allowed_in_project?(:edit_project_attributes, model)

def allow_edit_attributes_only = edit_project && !project_attributes_only && !edit_project_attributes

def allow_project_attributes_only
edit_project_attributes && (project_attributes_only || !edit_project)
end

def allow_all_attributes
(edit_project && edit_project_attributes && !project_attributes_only) ||
(changed_by_user == ["active"]) # Allow archiving, permission checked in manage_permission
end

def without_custom_fields(changes) = changes.grep_v(/^custom_field_/)

def with_custom_fields_only(changes) = changes.grep(/^custom_field_/)

def manage_permission
if changed_by_user == ["active"]
:archive_project
elsif project_attributes_only
:edit_project_attributes
else
# if "active" is changed, :archive_project permission will also be
# checked in `Projects::BaseContract#validate_changing_active`
Expand Down
36 changes: 28 additions & 8 deletions app/models/project_custom_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,38 @@ class ProjectCustomField < CustomField

validates :custom_field_section_id, presence: true

def type_name
:label_project_plural
end
class << self
def visible(user = User.current, project: nil)
if user.admin?
all
elsif user.allowed_in_any_project?(:select_project_custom_fields) || user.allowed_globally?(:add_project)
where(visible: true)
else
where(visible: true).where(mappings_with_view_project_attributes_permission(user, project).exists)
end
end

private

def mappings_with_view_project_attributes_permission(user, project) # rubocop:disable Metrics/AbcSize
allowed_projects = Project.allowed_to(user, :view_project_attributes)
mapping_table = ProjectCustomFieldProjectMapping.arel_table

def self.visible(user = User.current)
if user.admin?
all
else
where(visible: true)
mapping_condition = mapping_table[:custom_field_id].eq(arel_table[:id])
.and(mapping_table[:project_id].in(allowed_projects.select(:id).arel))

if project&.persisted?
mapping_condition = mapping_condition.and(mapping_table[:project_id].eq(project.id))
end

mapping_table.project(Arel.star).where(mapping_condition)
end
end

def type_name
:label_project_plural
end

def activate_required_field_in_all_projects
return unless required?

Expand Down
23 changes: 16 additions & 7 deletions app/models/projects/custom_fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,17 @@ module Projects::CustomFields
attr_accessor :_limit_custom_fields_validation_to_section_id

included do
has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :project_id,
dependent: :destroy, inverse_of: :project
has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: "ProjectCustomField"
has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping",
foreign_key: :project_id, dependent: :destroy,
inverse_of: :project
has_many :project_custom_fields, through: :project_custom_field_project_mappings,
class_name: "ProjectCustomField"

def available_custom_fields
visible_fields = all_available_custom_fields.visible
return visible_fields if new_record?
return all_visible_custom_fields if new_record?

visible_fields.where(id: project_custom_field_project_mappings.select(:custom_field_id))
.or(ProjectCustomField.required)
all_visible_custom_fields.where(id: project_custom_field_project_mappings.select(:custom_field_id))
.or(required_visible_custom_fields)
end

# Note:
Expand All @@ -59,6 +60,14 @@ def all_available_custom_fields
.order("custom_field_sections.position", :position_in_custom_field_section)
end

def all_visible_custom_fields
all_available_custom_fields.visible(project: self)
end

def required_visible_custom_fields
ProjectCustomField.required.visible(project: self)
end

def custom_field_values_to_validate
# Limit the set of available custom fields when the validation is limited to a section
if _limit_custom_fields_validation_to_section_id
Expand Down
5 changes: 4 additions & 1 deletion app/models/queries/filters/shared/custom_fields/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ def error_messages
protected

def condition
operator_strategy.sql_for_field(values_replaced, CustomValue.table_name, "value")
[
custom_field_context.where_subselect_conditions(custom_field, context),
operator_strategy.sql_for_field(values_replaced, CustomValue.table_name, "value")
].compact.join(" AND ")
end

def type_strategy_class
Expand Down
19 changes: 15 additions & 4 deletions app/models/queries/projects/filters/custom_field_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,29 @@ def custom_fields(_context)
end

def where_subselect_joins(custom_field)
cv_db_table = CustomValue.table_name
project_db_table = Project.table_name

<<~SQL.squish
LEFT OUTER JOIN #{cv_db_table}
ON #{cv_db_table}.customized_type='Project'
AND #{cv_db_table}.customized_id=#{project_db_table}.id
AND #{cv_db_table}.custom_field_id=#{custom_field.id}
INNER JOIN project_custom_field_project_mappings
ON project_custom_field_project_mappings.project_id = projects.id
ON project_custom_field_project_mappings.project_id = #{project_db_table}.id
AND project_custom_field_project_mappings.custom_field_id = #{custom_field.id}
SQL
end

def where_subselect_conditions(_custom_field, context)
# Allow searching projects only with :view_project_attributes permission
allowed_project_ids = Project.allowed_to(context.user, :view_project_attributes)
.select(:id)
<<~SQL.squish
#{project_db_table}.id IN (#{allowed_project_ids.to_sql})
SQL
end

private

def cv_db_table = CustomValue.table_name
def project_db_table = Project.table_name
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,9 @@ def where_subselect_joins(custom_field)

joins
end

def where_subselect_conditions(_custom_field, _context)
nil
end
end
end
4 changes: 4 additions & 0 deletions app/seeders/common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,13 +297,15 @@ project_roles:
- :view_calendar
- :comment_news
- :view_wiki_pages
- :view_project_attributes
- reference: :default_role_anonymous
t_name: Anonymous
position: 1
builtin: :anonymous
permissions:
- :view_work_packages
- :view_wiki_pages
- :view_project_attributes
- reference: :default_role_member
t_name: Member
position: 3
Expand Down Expand Up @@ -358,6 +360,7 @@ project_roles:
- :view_shared_work_packages
- :copy_work_packages
- :add_work_package_attachments
- :view_project_attributes
- reference: :default_role_reader
t_name: Reader
position: 4
Expand All @@ -381,6 +384,7 @@ project_roles:
- :view_commit_author_statistics
- :view_team_planner
- :view_shared_work_packages
- :view_project_attributes
- reference: :default_role_project_admin
t_name: Project admin
position: 5
Expand Down
12 changes: 12 additions & 0 deletions config/initializers/permissions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@
permissible_on: :project,
require: :member

map.permission :view_project_attributes,
{},
permissible_on: :project,
dependencies: :view_project

map.permission :edit_project_attributes,
{},
permissible_on: :project,
require: :member,
dependencies: :view_project_attributes,
contract_actions: { projects: %i[update] }

map.permission :select_project_custom_fields,
{
"projects/settings/project_custom_fields": %i[show toggle enable_all_of_section disable_all_of_section]
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2866,6 +2866,7 @@ en:
permission_edit_own_messages: "Edit own messages"
permission_edit_own_time_entries: "Edit own time logs"
permission_edit_project: "Edit project"
permission_edit_project_attributes: "Edit project attributes"
permission_edit_reportings: "Edit reportings"
permission_edit_time_entries: "Edit time logs for other users"
permission_edit_timelines: "Edit timelines"
Expand Down Expand Up @@ -2916,6 +2917,7 @@ en:
permission_work_package_assigned: "Become assignee/responsible"
permission_work_package_assigned_explanation: "Work packages can be assigned to users and groups in possession of this role in the respective project"
permission_view_project_activity: "View project activity"
permission_view_project_attributes: "View project attributes"
permission_save_bcf_queries: "Save BCF queries"
permission_manage_public_bcf_queries: "Manage public BCF queries"
permission_edit_attribute_help_texts: "Edit attribute help texts"
Expand Down
Loading

0 comments on commit 7013882

Please sign in to comment.