pull/627/head^2
chapsjust 2 months ago
parent 7cba39d076
commit 3bdfc55680

@ -16,6 +16,9 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration') @users.active.where.not(role: 'integration')
end end
# Restrict visibility to roles at or below the current user's rank.
@users = @users.where(role: Whitelabel.manageable_roles(current_user.role))
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)) @pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
end end
@ -40,7 +43,7 @@ class UsersController < ApplicationController
end end
@user.password = SecureRandom.hex if @user.password.blank? @user.password = SecureRandom.hex if @user.password.blank?
@user.role = User::ADMIN_ROLE unless role_valid?(@user.role) @user.role = User.admin_role unless role_valid?(@user.role)
if @user.save if @user.save
UserMailer.invitation_email(@user).deliver_later! UserMailer.invitation_email(@user).deliver_later!
@ -92,7 +95,8 @@ class UsersController < ApplicationController
private private
def role_valid?(role) def role_valid?(role)
User::ROLES.include?(role) # Role must exist AND be at or below the current user's own rank.
Whitelabel.manageable_roles(current_user.role).include?(role.to_s)
end end
def build_user def build_user

@ -53,6 +53,17 @@ class User < ApplicationRecord
USER_ROLE = 'user' USER_ROLE = 'user'
].freeze ].freeze
# Config-driven role list. Falls back to ROLES constant if no config.
def self.available_roles
Whitelabel.roles
rescue StandardError
ROLES
end
def self.admin_role
available_roles.first
end
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
FULL_EMAIL_REGEXP = FULL_EMAIL_REGEXP =
@ -72,12 +83,12 @@ class User < ApplicationRecord
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable
attribute :role, :string, default: ADMIN_ROLE attribute :role, :string, default: -> { User.admin_role }
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) } scope :admins, -> { where(role: admin_role) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ } validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
@ -96,7 +107,7 @@ class User < ApplicationRecord
def sidekiq? def sidekiq?
return true if Rails.env.development? return true if Rails.env.development?
role == 'admin' role == User.admin_role
end end
def self.sign_in_after_reset_password def self.sign_in_after_reset_password

@ -6,54 +6,57 @@
<span class="!bg-transparent"><%= t('settings') %></span> <span class="!bg-transparent"><%= t('settings') %></span>
</li> </li>
<li></li> <li></li>
<%# Profile is always visible — personal section %>
<li> <li>
<%= link_to t('profile'), settings_profile_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('profile'), settings_profile_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% if Whitelabel.setting_section_visible?(current_user.role, 'account') %>
<li> <li>
<%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %>
<% unless Docuseal.multitenant? %> <% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %> <% if Whitelabel.setting_section_visible?(current_user.role, 'email') && can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<li> <li>
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user %> <% if Whitelabel.setting_section_visible?(current_user.role, 'storage') && can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user %>
<li> <li>
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% end %> <% end %>
<% if can?(:read, AccountConfig) %> <% if Whitelabel.setting_section_visible?(current_user.role, 'notifications') && can?(:read, AccountConfig) %>
<li> <li>
<%= link_to t('notifications'), settings_notifications_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('notifications'), settings_notifications_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::ESIGN_CERTS_KEY, account: current_account)) %> <% if Whitelabel.setting_section_visible?(current_user.role, 'esign') && can?(:read, EncryptedConfig.new(key: EncryptedConfig::ESIGN_CERTS_KEY, account: current_account)) %>
<li> <li>
<%= link_to t('e_signature'), settings_esign_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('e_signature'), settings_esign_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, AccountConfig) %> <% if Whitelabel.setting_section_visible?(current_user.role, 'personalization') && can?(:read, AccountConfig) %>
<li> <li>
<%= link_to t('personalization'), settings_personalization_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('personalization'), settings_personalization_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, User) %> <% if Whitelabel.setting_section_visible?(current_user.role, 'users') && can?(:read, User) %>
<li> <li>
<%= link_to t('users'), settings_users_path, class: 'text-base hover:bg-base-300' %> <%= link_to t('users'), settings_users_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<%= render 'shared/settings_nav_extra' %> <%= render 'shared/settings_nav_extra' %>
<% if Docuseal.demo? || !Docuseal.multitenant? %> <% if Docuseal.demo? || !Docuseal.multitenant? %>
<% if can?(:read, AccessToken) && current_user.role != User::USER_ROLE %> <% if Whitelabel.setting_section_visible?(current_user.role, 'api') && can?(:read, AccessToken) %>
<li> <li>
<%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% end %> <% end %>
<% if Docuseal.demo? || !Docuseal.multitenant? || (current_user != true_user && !current_account.testing?) %> <% if Docuseal.demo? || !Docuseal.multitenant? || (current_user != true_user && !current_account.testing?) %>
<% if can?(:read, WebhookUrl) %> <% if Whitelabel.setting_section_visible?(current_user.role, 'webhooks') && can?(:read, WebhookUrl) %>
<li> <li>
<%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %>
</li> </li>

@ -1,9 +1,9 @@
<div class="form-control"> <div class="form-control">
<%= f.label :role, class: 'label' %> <%= f.label :role, class: 'label' %>
<%= f.select :role, nil, {}, class: 'base-select' do %> <%= f.select :role, nil, {}, class: 'base-select' do %>
<option value="admin"><%= t('role_admin') %></option> <% Whitelabel.manageable_roles(current_user.role).each do |role_slug| %>
<option value="gestionnaire"><%= t('role_gestionnaire') %></option> <option value="<%= role_slug %>"><%= t("role_#{role_slug}", default: role_slug.humanize) %></option>
<option value="user"><%= t('role_user') %></option> <% end %>
<% end %> <% end %>
<% if Docuseal.multitenant? %> <% if Docuseal.multitenant? %>
<label class="label"> <label class="label">

@ -4,8 +4,6 @@
> Do NOT add config schema details, API contracts, or example YAML here. > Do NOT add config schema details, API contracts, or example YAML here.
> The private config template is managed in the Intebec Dashboard, not in this repo. > The private config template is managed in the Intebec Dashboard, not in this repo.
## Usage in Code
### In ERB views ### In ERB views
```erb ```erb

@ -3,71 +3,101 @@
class Ability class Ability
include CanCan::Ability include CanCan::Ability
# Maps config resource names → CanCan model + action rules.
# All condition procs MUST return hashes (not AR relations) so that
# class-level can?/authorize! checks work (e.g. `authorize! :index, Template`).
RESOURCE_MAP = {
'templates' => [
[Template, :read, ->(u) { { account_id: u.account_id } }],
[Template, :create, ->(u) { { account_id: u.account_id } }],
[Template, :update, ->(u) { { account_id: u.account_id } }],
[Template, :destroy, ->(u) { { account_id: u.account_id } }],
[TemplateFolder, :manage, ->(u) { { account_id: u.account_id } }],
[TemplateSharing, :manage, ->(u) { { template: { account_id: u.account_id } } }]
],
'submissions' => [
[Submission, :manage, ->(u) { { account_id: u.account_id } }],
[Submitter, :manage, ->(u) { { account_id: u.account_id } }]
],
'users' => [
[User, :manage, ->(u) { { account_id: u.account_id } }]
],
'settings' => [
[EncryptedConfig, :manage, ->(u) { { account_id: u.account_id } }],
[AccountConfig, :manage, ->(u) { { account_id: u.account_id } }],
[Account, :manage, ->(u) { { id: u.account_id } }],
[WebhookUrl, :manage, ->(u) { { account_id: u.account_id } }]
]
}.freeze
def initialize(user) def initialize(user)
case user.role return unless user
when User::ADMIN_ROLE
admin_abilities(user) always_allowed(user)
when User::GESTIONNAIRE_ROLE apply_role_permissions(user)
gestionnaire_abilities(user)
when User::USER_ROLE
user_abilities(user)
end
end end
private private
# Accès complet à tout le compte # Personal resources — always available regardless of role.
def admin_abilities(user) def always_allowed(user)
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
Abilities::TemplateConditions.entity(template, user:, ability: 'manage')
end
can :destroy, Template, account_id: user.account_id
can :manage, TemplateFolder, account_id: user.account_id
can :manage, TemplateSharing, template: { account_id: user.account_id }
can :manage, Submission, account_id: user.account_id
can :manage, Submitter, account_id: user.account_id
can :manage, User, account_id: user.account_id
can :manage, EncryptedConfig, account_id: user.account_id
can :manage, EncryptedUserConfig, user_id: user.id can :manage, EncryptedUserConfig, user_id: user.id
can :manage, AccountConfig, account_id: user.account_id
can :manage, UserConfig, user_id: user.id can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_id can :manage, User, id: user.id
can :read, Account, id: user.account_id
can :manage, AccessToken, user_id: user.id can :manage, AccessToken, user_id: user.id
can :manage, WebhookUrl, account_id: user.account_id
end end
# Peut créer/gérer documents et envois — pas les paramètres ni les utilisateurs def apply_role_permissions(user)
def gestionnaire_abilities(user) role = user.role.to_s
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
Abilities::TemplateConditions.entity(template, user:, ability: 'manage') RESOURCE_MAP.each do |resource_key, model_rules|
config_actions = Whitelabel.role_permissions(role, resource_key)
model_rules.each do |model, cancan_action, condition_proc|
grant_if_allowed(user, model, cancan_action, condition_proc, config_actions)
end
end
end end
can :destroy, Template, account_id: user.account_id def grant_if_allowed(user, model, cancan_action, condition_proc, config_actions)
can :manage, TemplateFolder, account_id: user.account_id needed = action_to_config(cancan_action)
can :manage, TemplateSharing, template: { account_id: user.account_id } return unless (needed & config_actions).any?
can :manage, Submission, account_id: user.account_id
can :manage, Submitter, account_id: user.account_id conditions = condition_proc.call(user)
can :manage, EncryptedUserConfig, user_id: user.id granted = map_cancan_actions(cancan_action, config_actions)
can :manage, UserConfig, user_id: user.id return if granted.empty?
can :manage, User, id: user.id
can :read, Account, id: user.account_id # Hash-only conditions. Shared-template / linked-account filtering
can :manage, AccessToken, user_id: user.id # is handled at the controller level (TemplateConditions.collection,
# filter_templates, etc.) — CanCanCan forbids hash + block together.
can granted, model, conditions
end end
# Lecture seule — ne peut pas créer ni modifier # Map a CanCan :manage action to the individual config actions that are allowed.
def user_abilities(user) def map_cancan_actions(cancan_action, config_actions)
can :read, Template, Abilities::TemplateConditions.collection(user) do |template| if cancan_action == :manage
Abilities::TemplateConditions.entity(template, user:) mapped = []
mapped << :read if config_actions.include?('read')
mapped << :create if config_actions.include?('create')
mapped << :update if config_actions.include?('update')
mapped << :destroy if config_actions.include?('delete')
mapped
elsif cancan_action == :destroy
config_actions.include?('delete') ? [:destroy] : []
else
config_actions.include?(cancan_action.to_s) ? [cancan_action] : []
end
end end
can :read, TemplateFolder, account_id: user.account_id def action_to_config(cancan_action)
can :read, Submission, account_id: user.account_id case cancan_action
can :read, Submitter, account_id: user.account_id when :manage then %w[read create update delete]
can :manage, EncryptedUserConfig, user_id: user.id when :read then %w[read]
can :manage, UserConfig, user_id: user.id when :create then %w[create]
can :manage, User, id: user.id when :update then %w[update]
can :read, Account, id: user.account_id when :destroy then %w[delete]
can :manage, AccessToken, user_id: user.id else [cancan_action.to_s]
end
end end
end end

@ -332,6 +332,127 @@ module Whitelabel
dig_bool('features', 'show_pro_upsells', false) dig_bool('features', 'show_pro_upsells', false)
end end
# =====================================================================
# Roles & Permissions (config-driven)
# =====================================================================
#
# Config format:
# roles:
# admin:
# permissions:
# templates: [read, create, update, delete]
# submissions: [read, create, update, delete]
# users: [read, create, update, delete]
# settings: [read, create, update, delete]
# gestionnaire:
# permissions:
# templates: [read, create, update, delete]
# submissions: [read, create, update, delete]
# users: [read]
# settings: [read]
# user:
# permissions:
# templates: [read]
# submissions: [read]
#
# Default permission matrix — used when no roles section in config.
DEFAULT_ROLES = {
'admin' => {
'permissions' => {
'templates' => %w[read create update delete],
'submissions' => %w[read create update delete],
'users' => %w[read create update delete],
'settings' => %w[read create update delete]
}
},
'gestionnaire' => {
'permissions' => {
'templates' => %w[read create update delete],
'submissions' => %w[read create update delete],
'users' => %w[read],
'settings' => %w[read]
}
},
'user' => {
'permissions' => {
'templates' => %w[read],
'submissions' => %w[read],
'users' => [],
'settings' => []
}
}
}.freeze
# All available roles (keys). Order matters — first is the default.
def roles
(config.dig('roles') || DEFAULT_ROLES).keys
end
# The default role assigned to new users.
def default_role
roles.first
end
# Full role definition hash for a given role slug.
def role_definition(role_slug)
all = config.dig('roles') || DEFAULT_ROLES
all[role_slug.to_s] || {}
end
# Permission list for a role + resource.
# Returns e.g. ["read", "create", "update"] or [].
def role_permissions(role_slug, resource)
perms = role_definition(role_slug).dig('permissions', resource.to_s)
perms.is_a?(Array) ? perms : []
end
# Check if a role has a specific action on a resource.
def role_can?(role_slug, resource, action)
role_permissions(role_slug, resource).include?(action.to_s)
end
# Check if a role is an admin (first role in the list is always the admin).
def admin_role?(role_slug)
role_slug.to_s == roles.first
end
# Validate that a role slug exists in config.
def role_valid?(role_slug)
roles.include?(role_slug.to_s)
end
# Returns the rank index of a role (0 = highest privilege = admin).
# Unknown roles return roles.size (treated as lowest).
def role_rank(role_slug)
roles.index(role_slug.to_s) || roles.size
end
# Returns only roles that the given actor_role can assign/manage.
# An actor can only work with roles at their own rank or lower (higher index).
def manageable_roles(actor_role)
rank = role_rank(actor_role.to_s)
roles[rank..]
end
# All known settings sections in display order.
ALL_SETTINGS_SECTIONS = %w[account email storage notifications esign personalization users api webhooks].freeze
# Returns true if the role is allowed to see the given settings section.
# Falls back to ALL_SETTINGS_SECTIONS for roles that have settings read
# permission but no explicit sections list (backward-compatible).
def setting_section_visible?(role_slug, section)
defn = role_definition(role_slug)
sections = defn['settings_sections']
if sections.is_a?(Array)
sections.map(&:to_s).include?(section.to_s)
else
# No explicit list → grant all sections to roles that can read settings.
role_permissions(role_slug, 'settings').include?('read')
end
end
# ===================================================================== # =====================================================================
# Internal # Internal
# ===================================================================== # =====================================================================

Loading…
Cancel
Save