diff --git a/app/assets/javascripts/admin/commons/users.js b/app/assets/javascripts/admin/commons/users.js index 5ed2861f2d..ab7a3717f9 100644 --- a/app/assets/javascripts/admin/commons/users.js +++ b/app/assets/javascripts/admin/commons/users.js @@ -1,34 +1,91 @@ /*global $ */ -$(function () { - 'use strict'; +// Affiche la cible d'un rôle et n'y propose que les options du bon type. +// Le type attendu est porté par l'option de rôle sélectionnée +// (data-scope-type, vide = rôle global) ; chaque option de cible porte aussi +// son type. Aucun mapping en dur : tout vient du HTML rendu. +window.osuny.userRoles = { + init: function () { + 'use strict'; + var rows, + i; + if (!document.body.matches('.users-edit, .users-new, .users-update, .users-create')) { + return; + } + rows = document.querySelectorAll('[data-user-role]'); + for (i = 0; i < rows.length; i += 1) { + this.refreshRow(rows[i]); + } + this.initEvents(); + }, + + initEvents: function () { + 'use strict'; + document.addEventListener('change', this.onChangeRole.bind(this)); + $('#user_roles').on('cocoon:after-insert', this.onAfterInsert.bind(this)); + }, + + onChangeRole: function (event) { + 'use strict'; + var roleSelect = event.target.closest('[data-user-role-role]'); + if (roleSelect !== null) { + this.refreshRow(roleSelect.closest('[data-user-role]')); + } + }, + + onAfterInsert: function (event, row) { + 'use strict'; + this.refreshRow(row[0]); + }, + + refreshRow: function (row) { + 'use strict'; + var roleSelect = row.querySelector('[data-user-role-role]'), + roleOption = roleSelect && roleSelect.options[roleSelect.selectedIndex], + type = roleOption && roleOption.getAttribute('data-scope-type'), + container = row.querySelector('[data-user-role-scope-container]'), + scope = row.querySelector('[data-user-role-scope]'); - var changeRole = function () { - var value = $('select[name="user[role]"]').val(), - showForRoles, - required; - - $('*[data-show-for-roles]').each(function () { - showForRoles = $(this) - .attr('data-show-for-roles') - .split(','); - if ($.inArray(value, showForRoles) > -1) { - required = $(this).attr('data-required'); - if (required) { - $('input, select', this).attr('required', 'required'); - } else { - $('input, select', this).removeAttr('required'); - } - $(this).show(); - } else { - $(this).hide(); - // hidden field cannot be required - $('input, select', this).removeAttr('required'); + if (!type) { + container.classList.add('d-none'); + return; + } + container.classList.remove('d-none'); + this.filterScopeOptions(scope, type); + }, + + // N'affiche que les cibles du bon type et, si la cible courante n'est pas + // du bon type, sélectionne la première valide. + filterScopeOptions: function (scope, type) { + 'use strict'; + var options = scope.options, + firstMatch = null, + selected = scope.options[scope.selectedIndex], + matches, + i; + + for (i = 0; i < options.length; i += 1) { + matches = options[i].getAttribute('data-scope-type') === type; + options[i].hidden = !matches; + options[i].disabled = !matches; + if (matches && firstMatch === null) { + firstMatch = options[i].value; } - }); - }; + } + + if (!selected || selected.getAttribute('data-scope-type') !== type) { + scope.value = firstMatch; + } + }, - if ($('body').is('.users-edit, .users-new, .users-update, .users-create')) { - changeRole(); - $('select[name="user[role]"]').change(changeRole); + invoke: function () { + 'use strict'; + return { + init: this.init.bind(this) + }; } +}.invoke(); + +window.addEventListener('DOMContentLoaded', function () { + 'use strict'; + window.osuny.userRoles.init(); }); diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8b01237bd2..f9dc0a1317 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -81,7 +81,8 @@ def breadcrumb def user_params params.require(:user) - .permit(:email, :first_name, :last_name, :role, :language_id, :picture, :picture_delete, :picture_infos, :mobile_phone, programs_to_manage_ids: [], websites_to_manage_ids: []) + .permit(:email, :first_name, :last_name, :language_id, :picture, :picture_delete, :picture_infos, :mobile_phone, + roles_attributes: [:id, :role, :scope_choice, :_destroy]) .merge(university_id: current_university.id) end diff --git a/app/controllers/application_controller/with_maintenance.rb b/app/controllers/application_controller/with_maintenance.rb index 208913bae3..2b730c1479 100644 --- a/app/controllers/application_controller/with_maintenance.rb +++ b/app/controllers/application_controller/with_maintenance.rb @@ -8,6 +8,7 @@ module ApplicationController::WithMaintenance protected def check_maintenance + # TODO(roles-cache): prédicat sur le cache `role` -> has_role?('server_admin') si cache supprimé. if (ENV['MAINTENANCE'] == 'true') && !current_user&.server_admin? redirect_to '/maintenance' end diff --git a/app/controllers/extranet/application_controller.rb b/app/controllers/extranet/application_controller.rb index de64c15c1c..ff48ba7b83 100644 --- a/app/controllers/extranet/application_controller.rb +++ b/app/controllers/extranet/application_controller.rb @@ -28,6 +28,7 @@ def user_is_authorized? end def user_is_more_than_visitor + # TODO(roles-cache): visitor? = aucun rôle effectif. Sans cache -> roles.none?. !current_user.visitor? end diff --git a/app/controllers/server/application_controller.rb b/app/controllers/server/application_controller.rb index 6975a6949f..a85d75d241 100644 --- a/app/controllers/server/application_controller.rb +++ b/app/controllers/server/application_controller.rb @@ -10,6 +10,7 @@ def breadcrumb end def ensure_user_if_server_admin + # TODO(roles-cache): prédicat sur le cache `role` -> has_role?('server_admin') si cache supprimé. raise CanCan::AccessDenied unless current_user.server_admin? end diff --git a/app/helpers/admin/application_helper.rb b/app/helpers/admin/application_helper.rb index dfcdafd539..c5e82a6fa2 100644 --- a/app/helpers/admin/application_helper.rb +++ b/app/helpers/admin/application_helper.rb @@ -74,6 +74,7 @@ def publish_link(object) end def static_link(path) + # TODO(roles-cache): prédicat sur le cache `role` -> has_role?('server_admin') si cache supprimé. return unless current_user.server_admin? raw "#{t 'static' }" end diff --git a/app/models/ability.rb b/app/models/ability.rb index e98457ce2a..42dddc0243 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -4,7 +4,15 @@ class Ability include CanCan::Ability def self.for(user) - "Ability::#{user.role.classify}".constantize.new user + user ||= User.new # guest user (not logged in) + ability = new(user) + # Les rôles sont fusionnés du moins au plus privilégié (cf. User#ability_roles) + # pour que, en cas de conflit, la règle du rôle le plus puissant — déclarée + # en dernier — l'emporte (CanCanCan : last rule wins). + user.ability_roles.each do |role_name| + ability.merge "Ability::#{role_name.classify}".constantize.new(user) + end + ability end def initialize(user) @@ -13,7 +21,11 @@ def initialize(user) protected - def managed_websites_ids - @managed_websites_ids ||= @user.websites_to_manage.pluck(:communication_website_id) + # Ids des cibles attachées à un rôle donné de l'utilisateur. + # Chaque ability scope ses règles aux cibles de SON rôle (cf. User#scopes_for), + # ce qui permet d'être p. ex. author du site A et website_manager du site B + # sans que les périmètres ne débordent l'un sur l'autre. + def scoped_ids(role_name) + @user.scopes_for(role_name).map(&:id) end end diff --git a/app/models/ability/author.rb b/app/models/ability/author.rb index b314faaea9..0a273258ca 100644 --- a/app/models/ability/author.rb +++ b/app/models/ability/author.rb @@ -27,6 +27,12 @@ def initialize(user) protected + # Sites sur lesquels l'utilisateur est author. Surchargé en :contributor par + # Ability::Contributor, qui hérite des mêmes règles mais sur ses propres sites. + def managed_websites_ids + @managed_websites_ids ||= scoped_ids(:author) + end + def manage_records_based_on_abouts(record_class) can :manage, record_class, university_id: @user.university_id, about_type: 'Communication::Website::Agenda::Event::Localization', about_id: managed_agenda_event_localization_ids can :manage, record_class, university_id: @user.university_id, about_type: 'Communication::Website::Agenda::Exhibition::Localization', about_id: managed_agenda_exhibition_localization_ids diff --git a/app/models/ability/contributor.rb b/app/models/ability/contributor.rb index 32a29e5f69..2bf86ae08b 100644 --- a/app/models/ability/contributor.rb +++ b/app/models/ability/contributor.rb @@ -9,4 +9,12 @@ def initialize(user) cannot :publish, Communication::Website::Jobboard::Job end + protected + + # Hérite des règles d'author, mais scopées sur les sites où l'utilisateur est + # contributor (l'override est résolu dynamiquement, y compris pendant `super`). + def managed_websites_ids + @managed_websites_ids ||= scoped_ids(:contributor) + end + end \ No newline at end of file diff --git a/app/models/ability/program_manager.rb b/app/models/ability/program_manager.rb index 6af10383d5..2a060f840e 100644 --- a/app/models/ability/program_manager.rb +++ b/app/models/ability/program_manager.rb @@ -96,7 +96,7 @@ def managed_post_category_localization_ids end def managed_programs_ids - @managed_programs_ids ||= @user.programs_to_manage.pluck(:id) + @managed_programs_ids ||= scoped_ids(:program_manager) end def managed_program_localization_ids diff --git a/app/models/ability/website_manager.rb b/app/models/ability/website_manager.rb index 97012a719a..79d50a20a8 100644 --- a/app/models/ability/website_manager.rb +++ b/app/models/ability/website_manager.rb @@ -38,6 +38,11 @@ def initialize(user) protected + # Sites dont l'utilisateur est responsable (rôle website_manager). + def managed_websites_ids + @managed_websites_ids ||= scoped_ids(:website_manager) + end + def managed_agenda_category_localization_ids @managed_agenda_category_localization_ids ||= begin Communication::Website::Agenda::Category::Localization diff --git a/app/models/emergency_message.rb b/app/models/emergency_message.rb index c92419d4cb..54079edbb5 100644 --- a/app/models/emergency_message.rb +++ b/app/models/emergency_message.rb @@ -53,7 +53,9 @@ def to_s def target users = User.all - users = users.where(university_id: university_id) if university_id.present? + users = users.where(university_id: university_id) if university_id.present? + # TODO(roles-cache): cible par la colonne cache `role` (= rôle le plus élevé). + # Sans cache -> jointure sur `roles`. (cf. arbitrage dans User::WithRoles) users = users.where(role: role) if role.present? # next lines are to prevent to send the message to multiple occurrences of the same email (as for server_admin!) target_user_ids = users.select("DISTINCT ON (users.email) users.email, users.id").map(&:id) diff --git a/app/models/user/role.rb b/app/models/user/role.rb new file mode 100644 index 0000000000..131715bef3 --- /dev/null +++ b/app/models/user/role.rb @@ -0,0 +1,100 @@ +# == Schema Information +# +# Table name: user_roles +# +# id :uuid not null, primary key +# role :integer not null, uniquely indexed => [user_id, scope_type, scope_id] +# scope_type :string indexed => [scope_id], uniquely indexed => [user_id, role, scope_id] +# created_at :datetime not null +# updated_at :datetime not null +# scope_id :uuid indexed => [scope_type], uniquely indexed => [user_id, role, scope_type] +# university_id :uuid not null, indexed +# user_id :uuid not null, indexed, uniquely indexed => [role, scope_type, scope_id] +# +# Indexes +# +# index_user_roles_on_scope (scope_type,scope_id) +# index_user_roles_on_university_id (university_id) +# index_user_roles_on_user_id (user_id) +# index_user_roles_uniqueness (user_id,role,scope_type,scope_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_318345354e (user_id => users.id) +# fk_rails_c177139056 (university_id => universities.id) +# +class User::Role < ApplicationRecord + self.table_name = 'user_roles' + + # Source de vérité des droits : un utilisateur a autant de lignes que de + # couples (rôle, cible). Le scope est polymorphe et facultatif (les rôles + # globaux — teacher, admin, server_admin — n'ont pas de cible). + enum :role, User::WithRoles::ROLES + + belongs_to :user, inverse_of: :roles + belongs_to :university + belongs_to :scope, polymorphic: true, optional: true + + before_validation :set_university_from_user + before_validation :assign_scope_type_from_role + validate :scope_required_for_scoped_role + + # TODO(roles-cache): ces deux callbacks maintiennent le cache User#role ; ils + # disparaissent si l'équipe décide de supprimer le cache (cf. User::WithRoles). + after_save :refresh_user_cached_role + after_destroy :refresh_user_cached_role + after_create :autoset_favorite_for_website_manager + + def to_s + [ + I18n.t("activerecord.attributes.user.roles.#{role}"), + scope&.to_s + ].compact.join(' — ') + end + + # Pilote la cible via un seul champ de formulaire : on ne transmet que l'uuid, + # le type est déduit du rôle (cf. assign_scope_type_from_role). + def scope_choice + scope_id + end + + def scope_choice=(value) + self.scope_id = value.presence + end + + protected + + def set_university_from_user + self.university ||= user&.university + end + + # Le type de cible découle du rôle : pas besoin de le transmettre depuis le + # formulaire. Un rôle global n'a aucune cible. + def assign_scope_type_from_role + if global_role? + self.scope_type = nil + self.scope_id = nil + else + self.scope_type = User::WithRoles::SCOPED_ROLES[role] + end + end + + def scope_required_for_scoped_role + errors.add(:scope, 'is required for this role') if !global_role? && scope_id.blank? + end + + def global_role? + User::WithRoles::SCOPED_ROLES[role].nil? + end + + def refresh_user_cached_role + user&.refresh_cached_role! + end + + # Quand on confie un site à un responsable, on l'ajoute à ses favoris pour un + # accès rapide (idempotent). Remplace l'ancien autoset_favorites de User. + def autoset_favorite_for_website_manager + return unless website_manager? && scope.is_a?(Communication::Website) + user&.add_favorite(scope) + end +end diff --git a/app/models/user/with_favorites.rb b/app/models/user/with_favorites.rb index 66a8cb4636..d5a71a3b24 100644 --- a/app/models/user/with_favorites.rb +++ b/app/models/user/with_favorites.rb @@ -3,7 +3,6 @@ module User::WithFavorites included do has_many :favorites, dependent: :destroy - after_save :autoset_favorites end def add_favorite(about) @@ -23,12 +22,4 @@ def favorite?(about) def favorites_for(about) favorites.where(about_id: about.id, about_type: about.class.polymorphic_name) end - - def autoset_favorites - if saved_change_to_role? && website_manager? && favorites.none? - websites_to_manage.each do |website| - add_favorite(website) - end - end - end end diff --git a/app/models/user/with_roles.rb b/app/models/user/with_roles.rb index 14376a6b4e..7a81cfc385 100644 --- a/app/models/user/with_roles.rb +++ b/app/models/user/with_roles.rb @@ -1,40 +1,105 @@ module User::WithRoles extend ActiveSupport::Concern + # Encodage partagé par User (colonne `role`, cache du rôle le plus élevé) et + # par User::Role (source de vérité, scopée). Les entiers sont figés : ils sont + # stockés en base et utilisés par la migration de backfill. + ROLES = { + visitor: 0, + contributor: 4, + author: 5, + teacher: 10, + program_manager: 12, + website_manager: 15, + admin: 20, + server_admin: 30 + }.freeze + + # Rôles qui se rattachent à une ou plusieurs cibles (sites / formations). + # Les autres (visitor, teacher, admin, server_admin) sont globaux. + SCOPED_ROLES = { + 'contributor' => 'Communication::Website', + 'author' => 'Communication::Website', + 'website_manager' => 'Communication::Website', + 'program_manager' => 'Education::Program' + }.freeze + included do attr_accessor :modified_by, :just_autopromoted - enum :role, { - visitor: 0, - contributor: 4, - author: 5, - teacher: 10, - program_manager: 12, - website_manager: 15, - admin: 20, - server_admin: 30 - } - - has_and_belongs_to_many :programs_to_manage, - class_name: 'Education::Program', - join_table: :education_programs_users, - association_foreign_key: :education_program_id - - has_and_belongs_to_many :websites_to_manage, - class_name: 'Communication::Website', - join_table: :communication_websites_users, - association_foreign_key: :communication_website_id + # `role` reste une colonne : c'est un cache dénormalisé du rôle le plus élevé + # détenu, qui pilote la hiérarchie (managed_roles, menu, auto-promotion, sync, + # ciblage des messages…). La source de vérité des droits est `roles`. + # + # TODO(roles-cache): arbitrage d'équipe — garder ou supprimer ce cache ? + # Le cache `role` évite de réécrire tout l'existant (prédicats d'enum + # `server_admin?`/`visitor?`…, scopes SQL `where(role:)`, menu) mais impose + # une synchronisation cache <-> `roles` (cf. les TODO(roles-cache) ci-dessous : + # refresh_cached_role!, bootstrap_role_assignment, set_default_role, et les + # callbacks de User::Role). Le supprimer = tout dériver de `roles`, au prix de + # ~10-15 points d'appel à réécrire (extranet, sync, emergency_message, menu, + # vues). À trancher ensemble. + enum :role, ROLES + has_many :roles, + class_name: 'User::Role', + dependent: :destroy, + inverse_of: :user + accepts_nested_attributes_for :roles, reject_if: :all_blank, allow_destroy: true + + # TODO(roles-cache): requête sur la colonne cache ; sans cache -> jointure sur `roles`. scope :for_role, -> (role, language = nil) { where(role: role) } before_validation :set_default_role, on: :create - before_validation :check_modifier_role + validate :assigned_roles_within_modifier_scope + after_create :bootstrap_role_assignment after_create :set_university_autopromote_option, if: :just_autopromoted def self.roles_with_access_to_global_menu roles.keys - ['contributor', 'author', 'website_manager'] end + # Rôles effectifs (distincts), ordonnés du moins au plus privilégié. + # L'ordre est volontaire : Ability fusionne les abilities dans cet ordre pour + # que les règles du rôle le plus puissant soient déclarées en dernier et + # l'emportent (CanCanCan évalue de la dernière règle définie à la première). + def ability_roles + roles.map(&:role).uniq.sort_by { |name| self.class.roles[name] } + end + + # Détient ce rôle (sur n'importe quelle cible) ? + def has_role?(name) + roles.any? { |user_role| user_role.role == name.to_s } + end + + # Cibles (records) attachées à un rôle donné. + def scopes_for(role_name) + roles.select { |user_role| user_role.role == role_name.to_s } + .filter_map(&:scope) + end + + # Crée (idempotente) et retourne une attribution de rôle. Le cache `role` est + # rafraîchi par le callback after_save de User::Role. + def grant_role!(role_name, scope: nil) + roles.find_or_create_by!( + role: role_name, + scope_type: scope&.class&.base_class&.name, + scope_id: scope&.id + ) + end + + # TODO(roles-cache): toute cette méthode disparaît si on supprime le cache. + # Recalcule la colonne cache `role` à partir des roles. Les entiers de + # l'enum sont ordonnés par privilège croissant, donc MAX(role) = rôle le plus + # élevé détenu (ou visitor si aucun). + def refresh_cached_role! + return unless persisted? + new_value = roles.maximum(:role) || self.class.roles['visitor'] + update_column(:role, new_value) if read_attribute(:role) != new_value + end + + # TODO(roles-cache): s'appuie sur la colonne cache `role` ; sans cache, calculer + # le niveau le plus élevé depuis `roles`. def managed_roles User.roles.map do |role_name, role_id| next if role_id > User.roles[role] @@ -43,27 +108,45 @@ def managed_roles end def managed_websites - if server_admin? + if has_role?('server_admin') Communication::Website.all - elsif admin? + elsif has_role?('admin') Communication::Website.where(university_id: university_id) - elsif website_manager? - Communication::Website.where(id: websites_to_manage_ids) + elsif has_role?('website_manager') + Communication::Website.where(id: scopes_for('website_manager').map(&:id)) else Communication::Website.none end end def can_display_global_menu? - User.roles_with_access_to_global_menu.include?(role) + (ability_roles & User.roles_with_access_to_global_menu).any? end protected - def check_modifier_role - errors.add(:role, 'cannot be set to this role') if modified_by && !modified_by.managed_roles.include?(self.role) + # Empêche d'attribuer un rôle au-dessus de ce que le modificateur gère. + def assigned_roles_within_modifier_scope + return unless modified_by + assigned = roles.reject(&:marked_for_destruction?).map(&:role).uniq + forbidden = assigned - modified_by.managed_roles + errors.add(:base, 'cannot assign a role above your own') if forbidden.any? + end + + # TODO(roles-cache): pont cache -> `roles`. Sans cache, créer directement la + # ligne `roles` par défaut (le concept de "matérialiser le cache" disparaît). + # À la création, si aucune attribution n'a été fournie (création hors + # formulaire : tout premier utilisateur, premier de l'université, seeds…), + # on matérialise le rôle calculé par set_default_role en user_role. + def bootstrap_role_assignment + return if roles.any? + return if visitor? + grant_role!(role) end + # TODO(roles-cache): écrit le cache `role` (puis bootstrap_role_assignment le + # matérialise). Sans cache, cette règle d'auto-promotion créerait directement + # la ligne `roles` adéquate. def set_default_role return if server_admin? if User.all.empty? diff --git a/app/models/user/with_sync_between_universities.rb b/app/models/user/with_sync_between_universities.rb index 11aa0aae80..e96f59af8d 100644 --- a/app/models/user/with_sync_between_universities.rb +++ b/app/models/user/with_sync_between_universities.rb @@ -4,6 +4,9 @@ module User::WithSyncBetweenUniversities included do attr_accessor :skip_server_admin_sync + # TODO(roles-cache): server_admin? et le scope .server_admin reposent sur la + # colonne cache `role`. Sans cache -> has_role?('server_admin') et une + # jointure sur `roles`. (cf. arbitrage dans User::WithRoles) after_save :sync_between_universities, if: Proc.new { |user| user.server_admin? && !user.skip_server_admin_sync } after_destroy :remove_from_all_universities, if: Proc.new { |user| user.server_admin? && !user.skip_server_admin_sync } @@ -24,13 +27,16 @@ def sync_in_university(target_university) unless User.where(email: email, university_id: target_university.id).any? duplicate_user_for_university(target_university) else - User.find_by(email: email, university_id: target_university.id)&.update_columns( + existing = User.find_by(email: email, university_id: target_university.id) + existing&.update_columns( encrypted_password: self.encrypted_password, first_name: self.first_name, last_name: self.last_name, mobile_phone: self.mobile_phone, role: :server_admin ) + # `role` est un cache ; la source de vérité est roles. + existing&.grant_role!(:server_admin) end end diff --git a/app/views/admin/application/components/_nav.html.erb b/app/views/admin/application/components/_nav.html.erb index 6e6d78dd7d..7ba202c78b 100644 --- a/app/views/admin/application/components/_nav.html.erb +++ b/app/views/admin/application/components/_nav.html.erb @@ -96,6 +96,7 @@ languages = current_university.languages.ordered
<%= t('admin.users.roles_hint') %>
+