access_grant 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.codegraph/.gitignore +5 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +33 -0
- data/CONTRIBUTING.md +99 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +123 -0
- data/Rakefile +12 -0
- data/docs/architecture.md +1157 -0
- data/docs/proposal.md +143 -0
- data/docs/superpowers/plans/2026-09-08-access-grant-v1.md +468 -0
- data/docs/superpowers/plans/2026-09-08-gem-release.md +367 -0
- data/docs/superpowers/specs/2026-09-05-owner-role-design.md +271 -0
- data/docs/superpowers/specs/2026-09-07-proposal-review.md +71 -0
- data/docs/superpowers/specs/2026-09-07-usage-scenarios.md +301 -0
- data/docs/superpowers/specs/2026-09-08-gem-release-design.md +82 -0
- data/lib/access_grant/catalog/dsl.rb +138 -0
- data/lib/access_grant/catalog.rb +76 -0
- data/lib/access_grant/configuration.rb +55 -0
- data/lib/access_grant/controller_methods.rb +104 -0
- data/lib/access_grant/models/permission.rb +36 -0
- data/lib/access_grant/models/role.rb +152 -0
- data/lib/access_grant/models/role_permission.rb +11 -0
- data/lib/access_grant/owner.rb +144 -0
- data/lib/access_grant/permission_key.rb +29 -0
- data/lib/access_grant/railtie.rb +17 -0
- data/lib/access_grant/recovery.rb +90 -0
- data/lib/access_grant/sync.rb +68 -0
- data/lib/access_grant/tenant.rb +47 -0
- data/lib/access_grant/user.rb +102 -0
- data/lib/access_grant/version.rb +5 -0
- data/lib/access_grant.rb +125 -0
- data/lib/generators/access_grant/install/install_generator.rb +22 -0
- data/lib/generators/access_grant/install/templates/create_access_grant_tables.rb.tt +39 -0
- data/lib/generators/access_grant/setup/setup_generator.rb +188 -0
- data/lib/generators/access_grant/setup/templates/access_grant.rb.tt +80 -0
- data/lib/generators/access_grant/setup/templates/create_access_grant_user_roles.rb.tt +14 -0
- data/lib/generators/access_grant/setup/templates/permissions.rb.tt +10 -0
- data/lib/generators/access_grant/setup/templates/roles.rb.tt +27 -0
- data/lib/tasks/access_grant_tasks.rake +27 -0
- metadata +121 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/inflector"
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "catalog/dsl"
|
|
6
|
+
|
|
7
|
+
module AccessGrant
|
|
8
|
+
# In-memory permission catalog built from {AccessGrant.permissions}.
|
|
9
|
+
#
|
|
10
|
+
# Entries are hashes with keys +:key+, +:description+, +:category+.
|
|
11
|
+
# Persist them with {AccessGrant::Sync.call}.
|
|
12
|
+
class Catalog
|
|
13
|
+
# Default description templates for built-in actions (+%<resources>s+ /
|
|
14
|
+
# +%<resource>s+ filled from the pluralized resource segment).
|
|
15
|
+
DEFAULT_TEMPLATES = {
|
|
16
|
+
"index" => "Can view list of %<resources>s",
|
|
17
|
+
"show" => "Can view details of a %<resource>s",
|
|
18
|
+
"create" => "Can create a new %<resource>s",
|
|
19
|
+
"update" => "Can update an existing %<resource>s",
|
|
20
|
+
"destroy" => "Can delete an existing %<resource>s"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@entries = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<Hash>] catalog entries sorted by +:key+
|
|
28
|
+
def entries
|
|
29
|
+
@entries.values.sort_by { |entry| entry[:key] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Remove all entries.
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def clear!
|
|
36
|
+
@entries.clear
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Clear and evaluate a {DSL} block (replace semantics).
|
|
40
|
+
#
|
|
41
|
+
# @yield DSL
|
|
42
|
+
# @return [void]
|
|
43
|
+
def replace(&)
|
|
44
|
+
clear!
|
|
45
|
+
DSL.new(self).instance_eval(&)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Insert or override one catalog entry.
|
|
49
|
+
#
|
|
50
|
+
# @param key [String, Symbol] +resource.action+
|
|
51
|
+
# @param description [String]
|
|
52
|
+
# @param category [String] grouping label (often the resource name)
|
|
53
|
+
# @param override [Boolean] when true, replace an existing key instead of raising
|
|
54
|
+
# @return [void]
|
|
55
|
+
# @raise [AccessGrant::Error] invalid or duplicate key (when +override+ is false)
|
|
56
|
+
def add(key, description:, category:, override: false)
|
|
57
|
+
normalized = PermissionKey.normalize!(key)
|
|
58
|
+
|
|
59
|
+
raise Error, "Duplicate permission key: #{normalized}" if @entries.key?(normalized) && !override
|
|
60
|
+
|
|
61
|
+
@entries[normalized] = { key: normalized, description: description, category: category }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Render a {DEFAULT_TEMPLATES} description for +action+ / resource segment.
|
|
65
|
+
#
|
|
66
|
+
# @param action [String, Symbol]
|
|
67
|
+
# @param resource_segment [String] plural resource name (e.g. +"invoices"+)
|
|
68
|
+
# @return [String]
|
|
69
|
+
def template_description(action, resource_segment)
|
|
70
|
+
template = DEFAULT_TEMPLATES.fetch(action.to_s)
|
|
71
|
+
singular = resource_segment.singularize
|
|
72
|
+
|
|
73
|
+
format(template, resources: resource_segment, resource: singular)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Host-facing settings for AccessGrant. Set via {AccessGrant.configure}.
|
|
5
|
+
#
|
|
6
|
+
# Defaults match the architecture configuration reference; the setup
|
|
7
|
+
# generator writes a fully commented initializer covering every option.
|
|
8
|
+
class Configuration
|
|
9
|
+
# @return [String, nil] Host tenant model (e.g. +"Organization"+); +nil+ = single-tenant
|
|
10
|
+
attr_accessor :tenant_class
|
|
11
|
+
|
|
12
|
+
# @return [String] Host user model that receives roles (default: +"User"+)
|
|
13
|
+
attr_accessor :user_class
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] Owner mechanism: +:protected+, +:bypass+, +:both+, or +:none+ (default: +:protected+)
|
|
16
|
+
attr_accessor :owner_role
|
|
17
|
+
|
|
18
|
+
# @return [String] Reserved Owner role name, case-insensitive (default: +"Owner"+)
|
|
19
|
+
attr_accessor :owner_role_name
|
|
20
|
+
|
|
21
|
+
# @return [Hash{Symbol=>String}] Physical table names (+:roles+, +:permissions+, +:role_permissions+, +:user_roles+)
|
|
22
|
+
attr_accessor :tables
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] Actions emitted for each catalog +resource+ (default: CRUD + index/show)
|
|
25
|
+
attr_accessor :default_permission_actions
|
|
26
|
+
|
|
27
|
+
# @return [Symbol] Controller method for the acting user (default: +:current_user+)
|
|
28
|
+
attr_accessor :current_user_method
|
|
29
|
+
|
|
30
|
+
# @return [Symbol] Controller method for the tenant in multi-tenant mode (default: +:current_tenant+)
|
|
31
|
+
attr_accessor :current_tenant_method
|
|
32
|
+
|
|
33
|
+
# @return [Proc, nil] Called after tenant create; seed default roles (not Owner assignment)
|
|
34
|
+
attr_accessor :on_tenant_created
|
|
35
|
+
|
|
36
|
+
# @return [Proc, nil] Ops lockout recovery; +nil+ uses {AccessGrant::Recovery.grant_role!}
|
|
37
|
+
attr_accessor :recover_access
|
|
38
|
+
|
|
39
|
+
# Build a configuration with architecture defaults.
|
|
40
|
+
def initialize
|
|
41
|
+
@user_class = "User"
|
|
42
|
+
@owner_role = :protected
|
|
43
|
+
@owner_role_name = "Owner"
|
|
44
|
+
@default_permission_actions = %w[index show create update destroy]
|
|
45
|
+
@current_user_method = :current_user
|
|
46
|
+
@current_tenant_method = :current_tenant
|
|
47
|
+
@tables = {
|
|
48
|
+
roles: "roles",
|
|
49
|
+
permissions: "permissions",
|
|
50
|
+
role_permissions: "role_permissions",
|
|
51
|
+
user_roles: "user_roles"
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
|
|
6
|
+
module AccessGrant
|
|
7
|
+
# Optional controller authorize hook (not a second Pundit).
|
|
8
|
+
#
|
|
9
|
+
# Maps +controller_name+ + +action_name+ → +resource.action+, then calls
|
|
10
|
+
# {AccessGrant::User#permitted?} using configured current-user / tenant
|
|
11
|
+
# methods. Include automatically via {AccessGrant::Railtie}.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# class ApplicationController < ActionController::Base
|
|
15
|
+
# access_grant_authorize!
|
|
16
|
+
# skip_access_grant_authorize! if: :devise_controller?
|
|
17
|
+
# rescue_from AccessGrant::NotAuthorizedError, with: :deny
|
|
18
|
+
# end
|
|
19
|
+
module ControllerMethods
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
class_methods do
|
|
23
|
+
# Install a +before_action+ that runs {#access_grant_authorize_request!}.
|
|
24
|
+
#
|
|
25
|
+
# @param options [Hash] forwarded to +before_action+ (e.g. +only:+, +except:+)
|
|
26
|
+
# @return [void]
|
|
27
|
+
def access_grant_authorize!(**options)
|
|
28
|
+
if respond_to?(:before_action)
|
|
29
|
+
before_action :access_grant_authorize_request!, **options
|
|
30
|
+
else
|
|
31
|
+
access_grant_authorize_callbacks << [:access_grant_authorize_request!, options]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Skip the authorize before_action.
|
|
36
|
+
#
|
|
37
|
+
# @param options [Hash] forwarded to +skip_before_action+
|
|
38
|
+
# @return [void]
|
|
39
|
+
def skip_access_grant_authorize!(**options)
|
|
40
|
+
if respond_to?(:skip_before_action)
|
|
41
|
+
skip_before_action :access_grant_authorize_request!, **options
|
|
42
|
+
else
|
|
43
|
+
access_grant_skip_authorize_callbacks << [:access_grant_authorize_request!, options]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @api private
|
|
48
|
+
def access_grant_authorize_callbacks
|
|
49
|
+
@access_grant_authorize_callbacks ||= []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
def access_grant_skip_authorize_callbacks
|
|
54
|
+
@access_grant_skip_authorize_callbacks ||= []
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Authorize the current request. Raises {AccessGrant::NotAuthorizedError}
|
|
59
|
+
# on deny, missing user, or {AccessGrant::Error} from +permitted?+.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [AccessGrant::NotAuthorizedError]
|
|
63
|
+
def access_grant_authorize_request!
|
|
64
|
+
user = send(AccessGrant.config.current_user_method)
|
|
65
|
+
raise NotAuthorizedError, "No current user (#{AccessGrant.config.current_user_method})" if user.nil?
|
|
66
|
+
|
|
67
|
+
key = access_grant_permission_key
|
|
68
|
+
allowed =
|
|
69
|
+
begin
|
|
70
|
+
if access_grant_multi_tenant?
|
|
71
|
+
tenant = send(AccessGrant.config.current_tenant_method)
|
|
72
|
+
user.permitted?(key, tenant: tenant)
|
|
73
|
+
else
|
|
74
|
+
user.permitted?(key)
|
|
75
|
+
end
|
|
76
|
+
rescue Error => e
|
|
77
|
+
raise NotAuthorizedError.new(e.message), cause: e
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
raise NotAuthorizedError, "Not authorized for #{key}" unless allowed
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def access_grant_permission_key
|
|
86
|
+
"#{access_grant_resource_name}.#{action_name}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def access_grant_resource_name
|
|
90
|
+
name =
|
|
91
|
+
if respond_to?(:controller_name) && !controller_name.nil?
|
|
92
|
+
controller_name.to_s
|
|
93
|
+
else
|
|
94
|
+
self.class.name.demodulize.underscore.sub(/_controller\z/, "")
|
|
95
|
+
end
|
|
96
|
+
name.pluralize
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def access_grant_multi_tenant?
|
|
100
|
+
tenant_class = AccessGrant.config.tenant_class
|
|
101
|
+
!(tenant_class.nil? || tenant_class.to_s.empty?)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Catalog permission row (+key+, +description+, +category+).
|
|
5
|
+
#
|
|
6
|
+
# Metadata is owned by sync/code; admins select keys but do not invent them.
|
|
7
|
+
class Permission < ActiveRecord::Base
|
|
8
|
+
self.table_name = AccessGrant.config.tables.fetch(:permissions)
|
|
9
|
+
|
|
10
|
+
validates :key, presence: true, uniqueness: true
|
|
11
|
+
validate :key_must_be_valid_permission_key
|
|
12
|
+
|
|
13
|
+
# Permissions for one model/controller grouping label (default = resource
|
|
14
|
+
# name, e.g. +"invoices"+). Uses index on +(category, key)+.
|
|
15
|
+
#
|
|
16
|
+
# @param category [String, Symbol]
|
|
17
|
+
# @return [ActiveRecord::Relation]
|
|
18
|
+
scope :by_category, lambda { |category|
|
|
19
|
+
where(category: category.to_s).order(:key)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Full catalog ordered for admin UIs (group by category, then key).
|
|
23
|
+
#
|
|
24
|
+
# @return [ActiveRecord::Relation]
|
|
25
|
+
scope :ordered_for_ui, -> { order(:category, :key) }
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def key_must_be_valid_permission_key
|
|
30
|
+
return if key.blank?
|
|
31
|
+
return if PermissionKey.valid?(key)
|
|
32
|
+
|
|
33
|
+
errors.add(:key, "is invalid")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Named role within a tenant (or global when +tenant_id+ is +nil+).
|
|
5
|
+
#
|
|
6
|
+
# Assign permissions with {#permission_keys=}. Owner roles are reserved and
|
|
7
|
+
# protected according to {AccessGrant::Configuration#owner_role}.
|
|
8
|
+
class Role < ActiveRecord::Base
|
|
9
|
+
self.table_name = AccessGrant.config.tables.fetch(:roles)
|
|
10
|
+
|
|
11
|
+
has_many :role_permissions, class_name: "AccessGrant::RolePermission", dependent: :destroy,
|
|
12
|
+
inverse_of: :role,
|
|
13
|
+
before_add: :prevent_protected_owner_permission_change,
|
|
14
|
+
before_remove: :prevent_protected_owner_permission_change
|
|
15
|
+
has_many :permissions, through: :role_permissions, class_name: "AccessGrant::Permission",
|
|
16
|
+
before_add: :prevent_protected_owner_permission_change,
|
|
17
|
+
before_remove: :prevent_protected_owner_permission_change
|
|
18
|
+
|
|
19
|
+
validates :name, presence: true,
|
|
20
|
+
uniqueness: { scope: :tenant_id, case_sensitive: false }
|
|
21
|
+
validate :name_not_reserved_owner
|
|
22
|
+
before_destroy :prevent_protected_owner_destroy, prepend: true
|
|
23
|
+
|
|
24
|
+
# @return [Array<String>] permission keys currently granted
|
|
25
|
+
def permission_keys
|
|
26
|
+
permissions.pluck(:key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Atomically replace the full permission set. Unknown/malformed keys raise
|
|
30
|
+
# and leave the previous set intact.
|
|
31
|
+
#
|
|
32
|
+
# @param keys [Array<String, Symbol>]
|
|
33
|
+
# @return [Array<AccessGrant::Permission>]
|
|
34
|
+
# @raise [AccessGrant::Error] protected Owner, unknown key, or malformed key
|
|
35
|
+
def permission_keys=(keys)
|
|
36
|
+
raise Error, "Cannot modify permissions on protected Owner role" if protected_owner_role?
|
|
37
|
+
|
|
38
|
+
normalized = Array(keys).map { |key| PermissionKey.normalize!(key) }
|
|
39
|
+
|
|
40
|
+
transaction do
|
|
41
|
+
resolved = normalized.map do |key|
|
|
42
|
+
Permission.find_by(key: key) ||
|
|
43
|
+
raise(Error, "Unknown permission key: #{key.inspect}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
self.permissions = resolved
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true when this is the Owner role under +:protected+ / +:both+
|
|
51
|
+
def protected_owner_role?
|
|
52
|
+
return false unless %i[protected both].include?(AccessGrant.config.owner_role)
|
|
53
|
+
|
|
54
|
+
owner_named?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] name matches configured Owner name (case-insensitive)
|
|
58
|
+
def owner_named?
|
|
59
|
+
name.to_s.downcase == AccessGrant.config.owner_role_name.to_s.downcase
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Create-only: for each name => permission keys, creates the role when missing
|
|
63
|
+
# under +tenant+ (or +tenant_id+ nil when +tenant+ is nil) and assigns keys.
|
|
64
|
+
# Existing roles are left unchanged.
|
|
65
|
+
#
|
|
66
|
+
# @param tenant [Object, nil] tenant record responding to +id+, or nil (single-tenant)
|
|
67
|
+
# @param defaults [Hash] role name => array of permission key strings/symbols
|
|
68
|
+
# @return [void]
|
|
69
|
+
def self.ensure_defaults_for!(tenant, defaults)
|
|
70
|
+
tenant_id = tenant&.id
|
|
71
|
+
defaults.each do |role_name, keys|
|
|
72
|
+
next if exists?(name: role_name.to_s, tenant_id: tenant_id)
|
|
73
|
+
|
|
74
|
+
role = create!(name: role_name.to_s, tenant_id: tenant_id)
|
|
75
|
+
role.permission_keys = keys
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Starting-point roles derived from synced permissions, grouped by +category+
|
|
80
|
+
# (resource / model name). Create-only; safe to call on every tenant create.
|
|
81
|
+
#
|
|
82
|
+
# For category +"invoices"+ with keys +invoices.index+, +invoices.show+, …:
|
|
83
|
+
# - +"Invoices Viewer"+ — actions in +viewer_actions+ that exist
|
|
84
|
+
# - +"Invoices Manager"+ — all keys in that category
|
|
85
|
+
#
|
|
86
|
+
# Requires a prior +access_grant:sync_permissions+ so Permission rows exist.
|
|
87
|
+
# Hosts customize or replace this from +config/access_grant/roles.rb+.
|
|
88
|
+
#
|
|
89
|
+
# @param tenant [Object, nil]
|
|
90
|
+
# @param viewer_actions [Array<String>] action suffixes for Viewer roles
|
|
91
|
+
# @return [void]
|
|
92
|
+
def self.ensure_resource_defaults_for!(tenant, viewer_actions: %w[index show])
|
|
93
|
+
viewer_actions = viewer_actions.map(&:to_s)
|
|
94
|
+
defaults = resource_default_role_map(viewer_actions)
|
|
95
|
+
ensure_defaults_for!(tenant, defaults)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.resource_default_role_map(viewer_actions)
|
|
99
|
+
defaults = {}
|
|
100
|
+
permissions_by_category.each do |category, permissions|
|
|
101
|
+
label = humanize_category(category)
|
|
102
|
+
keys = permissions.map(&:key)
|
|
103
|
+
viewer_keys = keys.select { |key| viewer_actions.include?(key.split(".", 2).last) }
|
|
104
|
+
|
|
105
|
+
defaults["#{label} Viewer"] = viewer_keys if viewer_keys.any?
|
|
106
|
+
defaults["#{label} Manager"] = keys if keys.any?
|
|
107
|
+
end
|
|
108
|
+
defaults
|
|
109
|
+
end
|
|
110
|
+
private_class_method :resource_default_role_map
|
|
111
|
+
|
|
112
|
+
def self.permissions_by_category
|
|
113
|
+
grouped = Hash.new { |hash, key| hash[key] = [] }
|
|
114
|
+
Permission.select(:id, :key, :category).find_each do |permission|
|
|
115
|
+
category = permission.category.presence || permission.key.split(".", 2).first
|
|
116
|
+
grouped[category] << permission
|
|
117
|
+
end
|
|
118
|
+
grouped
|
|
119
|
+
end
|
|
120
|
+
private_class_method :permissions_by_category
|
|
121
|
+
|
|
122
|
+
def self.humanize_category(category)
|
|
123
|
+
category.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
124
|
+
end
|
|
125
|
+
private_class_method :humanize_category
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def name_not_reserved_owner
|
|
130
|
+
return if Owner.__send__(:owner_role_creation?)
|
|
131
|
+
return if AccessGrant.config.owner_role == :none
|
|
132
|
+
return unless owner_named?
|
|
133
|
+
|
|
134
|
+
errors.add(:name, "is reserved for the Owner role")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def prevent_protected_owner_destroy
|
|
138
|
+
return unless protected_owner_role?
|
|
139
|
+
return if destroyed_by_association # tenant/org cascading destroy
|
|
140
|
+
|
|
141
|
+
errors.add(:base, "Cannot destroy protected Owner role")
|
|
142
|
+
throw(:abort)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def prevent_protected_owner_permission_change(_record)
|
|
146
|
+
return unless protected_owner_role?
|
|
147
|
+
return if destroyed_by_association # tenant/org cascading destroy
|
|
148
|
+
|
|
149
|
+
raise Error, "Cannot modify permissions on protected Owner role"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Join between {Role} and {Permission}.
|
|
5
|
+
class RolePermission < ActiveRecord::Base
|
|
6
|
+
self.table_name = AccessGrant.config.tables.fetch(:role_permissions)
|
|
7
|
+
|
|
8
|
+
belongs_to :role, class_name: "AccessGrant::Role"
|
|
9
|
+
belongs_to :permission, class_name: "AccessGrant::Permission"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Owner role grant/revoke and last-Owner assignment guards.
|
|
5
|
+
#
|
|
6
|
+
# Prefer +tenant.grant_owner!(user)+ / +tenant.revoke_owner!(user)+ when
|
|
7
|
+
# multi-tenant; use {AccessGrant.grant_owner!} in single-tenant mode.
|
|
8
|
+
module Owner
|
|
9
|
+
# @api private
|
|
10
|
+
OWNER_ROLE_CREATION_KEY = :access_grant_owner_role_creation
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @api private
|
|
15
|
+
def with_owner_role_creation
|
|
16
|
+
previous = Thread.current[OWNER_ROLE_CREATION_KEY]
|
|
17
|
+
Thread.current[OWNER_ROLE_CREATION_KEY] = true
|
|
18
|
+
yield
|
|
19
|
+
ensure
|
|
20
|
+
Thread.current[OWNER_ROLE_CREATION_KEY] = previous
|
|
21
|
+
end
|
|
22
|
+
private_class_method :with_owner_role_creation
|
|
23
|
+
|
|
24
|
+
# @api private
|
|
25
|
+
def owner_role_creation?
|
|
26
|
+
Thread.current[OWNER_ROLE_CREATION_KEY] == true
|
|
27
|
+
end
|
|
28
|
+
private_class_method :owner_role_creation?
|
|
29
|
+
|
|
30
|
+
# Find or create the Owner role for the scope, attach catalog keys when
|
|
31
|
+
# mode is +:protected+ / +:both+, and assign +user+.
|
|
32
|
+
#
|
|
33
|
+
# @param user [Object] host user with +access_grant :user+
|
|
34
|
+
# @param tenant [Object, nil] required in multi-tenant mode
|
|
35
|
+
# @return [AccessGrant::Role]
|
|
36
|
+
# @raise [AccessGrant::Error]
|
|
37
|
+
def grant_owner!(user, tenant: nil)
|
|
38
|
+
ensure_owner_enabled!
|
|
39
|
+
ensure_tenant_arg!(tenant)
|
|
40
|
+
|
|
41
|
+
role = find_or_create_owner_role!(tenant_id_for(tenant))
|
|
42
|
+
attach_owner_permissions!(role) if attach_owner_permissions?
|
|
43
|
+
|
|
44
|
+
user.roles << role unless user.roles.exists?(id: role.id)
|
|
45
|
+
role
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Remove Owner from +user+. Raises if this is the last Owner assignment
|
|
49
|
+
# for the scope (also enforced on HABTM +roles.delete+).
|
|
50
|
+
#
|
|
51
|
+
# @param user [Object]
|
|
52
|
+
# @param tenant [Object, nil]
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @raise [AccessGrant::Error]
|
|
55
|
+
def revoke_owner!(user, tenant: nil)
|
|
56
|
+
ensure_owner_enabled!
|
|
57
|
+
ensure_tenant_arg!(tenant)
|
|
58
|
+
|
|
59
|
+
role = find_owner_role(tenant_id_for(tenant))
|
|
60
|
+
return unless role && user.roles.exists?(id: role.id)
|
|
61
|
+
|
|
62
|
+
Role.transaction do
|
|
63
|
+
user.roles.delete(role)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Guard used by HABTM +before_remove+ and revoke paths.
|
|
68
|
+
#
|
|
69
|
+
# @param role [AccessGrant::Role]
|
|
70
|
+
# @return [void]
|
|
71
|
+
# @raise [AccessGrant::Error] when removing the last Owner assignment
|
|
72
|
+
def ensure_can_remove_assignment!(role)
|
|
73
|
+
return if AccessGrant.config.owner_role == :none
|
|
74
|
+
return unless role.owner_named?
|
|
75
|
+
|
|
76
|
+
role.lock!
|
|
77
|
+
return if assignment_count(role) > 1
|
|
78
|
+
|
|
79
|
+
raise Error, "Cannot revoke the last Owner for this scope"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ensure_owner_enabled!
|
|
83
|
+
return unless AccessGrant.config.owner_role == :none
|
|
84
|
+
|
|
85
|
+
raise Error, "Owner is disabled (owner_role: :none)"
|
|
86
|
+
end
|
|
87
|
+
private_class_method :ensure_owner_enabled!
|
|
88
|
+
|
|
89
|
+
def ensure_tenant_arg!(tenant)
|
|
90
|
+
if multi_tenant?
|
|
91
|
+
raise Error, "tenant: is required in multi-tenant mode" if tenant.nil?
|
|
92
|
+
else
|
|
93
|
+
raise Error, "tenant: must not be supplied in single-tenant mode" unless tenant.nil?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
private_class_method :ensure_tenant_arg!
|
|
97
|
+
|
|
98
|
+
def tenant_id_for(tenant)
|
|
99
|
+
tenant&.id
|
|
100
|
+
end
|
|
101
|
+
private_class_method :tenant_id_for
|
|
102
|
+
|
|
103
|
+
def multi_tenant?
|
|
104
|
+
tenant_class = AccessGrant.config.tenant_class
|
|
105
|
+
!(tenant_class.nil? || tenant_class.to_s.empty?)
|
|
106
|
+
end
|
|
107
|
+
private_class_method :multi_tenant?
|
|
108
|
+
|
|
109
|
+
def attach_owner_permissions?
|
|
110
|
+
%i[protected both].include?(AccessGrant.config.owner_role)
|
|
111
|
+
end
|
|
112
|
+
private_class_method :attach_owner_permissions?
|
|
113
|
+
|
|
114
|
+
def attach_owner_permissions!(role)
|
|
115
|
+
Sync.__send__(:replace_role_permissions!, role, Permission.pluck(:key))
|
|
116
|
+
end
|
|
117
|
+
private_class_method :attach_owner_permissions!
|
|
118
|
+
|
|
119
|
+
def find_or_create_owner_role!(tenant_id)
|
|
120
|
+
existing = find_owner_role(tenant_id)
|
|
121
|
+
return existing if existing
|
|
122
|
+
|
|
123
|
+
with_owner_role_creation do
|
|
124
|
+
Role.create!(name: AccessGrant.config.owner_role_name.to_s, tenant_id: tenant_id)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
private_class_method :find_or_create_owner_role!
|
|
128
|
+
|
|
129
|
+
def find_owner_role(tenant_id)
|
|
130
|
+
owner_name = AccessGrant.config.owner_role_name.to_s
|
|
131
|
+
Role.where(tenant_id: tenant_id).where("LOWER(name) = ?", owner_name.downcase).first
|
|
132
|
+
end
|
|
133
|
+
private_class_method :find_owner_role
|
|
134
|
+
|
|
135
|
+
def assignment_count(role)
|
|
136
|
+
join_table = AccessGrant.config.tables.fetch(:user_roles)
|
|
137
|
+
sql = ActiveRecord::Base.sanitize_sql_array(
|
|
138
|
+
["SELECT COUNT(*) FROM #{join_table} WHERE role_id = ?", role.id]
|
|
139
|
+
)
|
|
140
|
+
ActiveRecord::Base.connection.select_value(sql).to_i
|
|
141
|
+
end
|
|
142
|
+
private_class_method :assignment_count
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Validates and normalizes permission keys in +resource.action+ form.
|
|
5
|
+
#
|
|
6
|
+
# Pattern: +/\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z/+
|
|
7
|
+
class PermissionKey
|
|
8
|
+
# @return [Regexp] enforced key shape
|
|
9
|
+
PATTERN = /\A[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\z/
|
|
10
|
+
|
|
11
|
+
# @param key [String, Symbol]
|
|
12
|
+
# @return [Boolean]
|
|
13
|
+
def self.valid?(key)
|
|
14
|
+
PATTERN.match?(key.to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Coerce to String and validate.
|
|
18
|
+
#
|
|
19
|
+
# @param key [String, Symbol]
|
|
20
|
+
# @return [String] normalized key
|
|
21
|
+
# @raise [AccessGrant::Error] when the key is malformed
|
|
22
|
+
def self.normalize!(key)
|
|
23
|
+
normalized = key.to_s
|
|
24
|
+
raise Error, "Invalid permission key: #{key.inspect}" unless valid?(normalized)
|
|
25
|
+
|
|
26
|
+
normalized
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Rails integration: loads rake tasks and includes {ControllerMethods} on
|
|
5
|
+
# Action Controller.
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
rake_tasks do
|
|
8
|
+
load File.expand_path("../tasks/access_grant_tasks.rake", __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "access_grant.action_controller" do
|
|
12
|
+
ActiveSupport.on_load(:action_controller) do
|
|
13
|
+
include AccessGrant::ControllerMethods
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|