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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Ops lockout recovery used by +rake access_grant:grant_role+.
|
|
5
|
+
#
|
|
6
|
+
# Override via {AccessGrant::Configuration#recover_access}.
|
|
7
|
+
module Recovery
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Grant a named role (or Owner) to a user by id.
|
|
11
|
+
#
|
|
12
|
+
# When +role_name+ matches {AccessGrant::Configuration#owner_role_name}
|
|
13
|
+
# (case-insensitive) and Owner is enabled, delegates to
|
|
14
|
+
# {AccessGrant::Owner.grant_owner!}.
|
|
15
|
+
#
|
|
16
|
+
# @param role_name [String, Symbol]
|
|
17
|
+
# @param user_id [Integer]
|
|
18
|
+
# @param tenant_id [Integer, nil] required in multi-tenant mode
|
|
19
|
+
# @return [AccessGrant::Role]
|
|
20
|
+
# @raise [AccessGrant::Error]
|
|
21
|
+
def grant_role!(role_name:, user_id:, tenant_id: nil)
|
|
22
|
+
user = user_class.find(user_id)
|
|
23
|
+
|
|
24
|
+
if owner_role_request?(role_name)
|
|
25
|
+
tenant = resolve_tenant_for_owner!(tenant_id)
|
|
26
|
+
return Owner.grant_owner!(user, tenant: tenant)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ensure_tenant_id_for_role!(tenant_id)
|
|
30
|
+
role = find_role!(role_name, tenant_id: tenant_id)
|
|
31
|
+
user.roles << role unless user.roles.exists?(id: role.id)
|
|
32
|
+
role
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def owner_role_request?(role_name)
|
|
36
|
+
return false if AccessGrant.config.owner_role == :none
|
|
37
|
+
|
|
38
|
+
role_name.to_s.casecmp?(AccessGrant.config.owner_role_name.to_s)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :owner_role_request?
|
|
41
|
+
|
|
42
|
+
def user_class
|
|
43
|
+
AccessGrant.config.user_class.constantize
|
|
44
|
+
end
|
|
45
|
+
private_class_method :user_class
|
|
46
|
+
|
|
47
|
+
def resolve_tenant_for_owner!(tenant_id)
|
|
48
|
+
if multi_tenant?
|
|
49
|
+
raise Error, "tenant_id is required in multi-tenant mode" if tenant_id.nil?
|
|
50
|
+
|
|
51
|
+
tenant_class.find(tenant_id)
|
|
52
|
+
else
|
|
53
|
+
raise Error, "tenant_id must not be supplied in single-tenant mode" unless tenant_id.nil?
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
private_class_method :resolve_tenant_for_owner!
|
|
59
|
+
|
|
60
|
+
def ensure_tenant_id_for_role!(tenant_id)
|
|
61
|
+
if multi_tenant?
|
|
62
|
+
raise Error, "tenant_id is required in multi-tenant mode" if tenant_id.nil?
|
|
63
|
+
else
|
|
64
|
+
raise Error, "tenant_id must not be supplied in single-tenant mode" unless tenant_id.nil?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_class_method :ensure_tenant_id_for_role!
|
|
68
|
+
|
|
69
|
+
def find_role!(role_name, tenant_id:)
|
|
70
|
+
role = Role.where(tenant_id: tenant_id)
|
|
71
|
+
.where("LOWER(name) = ?", role_name.to_s.downcase)
|
|
72
|
+
.first
|
|
73
|
+
raise Error, "Role not found: #{role_name.inspect}" unless role
|
|
74
|
+
|
|
75
|
+
role
|
|
76
|
+
end
|
|
77
|
+
private_class_method :find_role!
|
|
78
|
+
|
|
79
|
+
def multi_tenant?
|
|
80
|
+
tenant_class_name = AccessGrant.config.tenant_class
|
|
81
|
+
!(tenant_class_name.nil? || tenant_class_name.to_s.empty?)
|
|
82
|
+
end
|
|
83
|
+
private_class_method :multi_tenant?
|
|
84
|
+
|
|
85
|
+
def tenant_class
|
|
86
|
+
AccessGrant.config.tenant_class.constantize
|
|
87
|
+
end
|
|
88
|
+
private_class_method :tenant_class
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Upserts the in-memory catalog into the permissions table.
|
|
5
|
+
#
|
|
6
|
+
# Never deletes orphaned DB keys. For +:protected+ / +:both+, re-attaches
|
|
7
|
+
# every permission key to Owner-named roles.
|
|
8
|
+
#
|
|
9
|
+
# Invoked by +rake access_grant:sync_permissions+.
|
|
10
|
+
class Sync
|
|
11
|
+
# @api private
|
|
12
|
+
REATTACH_OWNER_MODES = %i[protected both].freeze
|
|
13
|
+
|
|
14
|
+
# Persist catalog entries (description/category) and optionally reattach
|
|
15
|
+
# Owner permissions.
|
|
16
|
+
#
|
|
17
|
+
# @param catalog [AccessGrant::Catalog]
|
|
18
|
+
# @return [void]
|
|
19
|
+
def self.call(catalog: AccessGrant.catalog)
|
|
20
|
+
ActiveRecord::Base.transaction do
|
|
21
|
+
catalog.entries.each do |entry|
|
|
22
|
+
permission = Permission.find_or_initialize_by(key: entry.fetch(:key))
|
|
23
|
+
permission.description = entry[:description]
|
|
24
|
+
permission.category = entry[:category]
|
|
25
|
+
permission.save!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
reattach_owner_permissions! if reattach_owner?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @api private
|
|
33
|
+
def self.replace_role_permissions!(role, keys)
|
|
34
|
+
normalized = Array(keys).map { |key| PermissionKey.normalize!(key) }
|
|
35
|
+
|
|
36
|
+
Role.transaction do
|
|
37
|
+
resolved = resolve_permissions!(normalized)
|
|
38
|
+
role.role_permissions.delete_all
|
|
39
|
+
resolved.each do |permission|
|
|
40
|
+
RolePermission.create!(role_id: role.id, permission_id: permission.id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_class_method :replace_role_permissions!
|
|
45
|
+
|
|
46
|
+
def self.resolve_permissions!(keys)
|
|
47
|
+
keys.map do |key|
|
|
48
|
+
Permission.find_by(key: key) || raise(Error, "Unknown permission key: #{key.inspect}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :resolve_permissions!
|
|
52
|
+
|
|
53
|
+
def self.reattach_owner?
|
|
54
|
+
REATTACH_OWNER_MODES.include?(AccessGrant.config.owner_role)
|
|
55
|
+
end
|
|
56
|
+
private_class_method :reattach_owner?
|
|
57
|
+
|
|
58
|
+
def self.reattach_owner_permissions!
|
|
59
|
+
owner_name = AccessGrant.config.owner_role_name.to_s
|
|
60
|
+
keys = Permission.pluck(:key)
|
|
61
|
+
|
|
62
|
+
Role.where("LOWER(name) = ?", owner_name.downcase).find_each do |role|
|
|
63
|
+
replace_role_permissions!(role, keys)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
private_class_method :reattach_owner_permissions!
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Mixed into the host tenant model via +access_grant :tenant+.
|
|
5
|
+
#
|
|
6
|
+
# Provides +has_many :roles+, {#grant_owner!}, {#revoke_owner!}, and
|
|
7
|
+
# optional {AccessGrant::Configuration#on_tenant_created} after create.
|
|
8
|
+
module Tenant
|
|
9
|
+
# @api private
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.has_many :roles,
|
|
12
|
+
class_name: "AccessGrant::Role",
|
|
13
|
+
foreign_key: :tenant_id,
|
|
14
|
+
dependent: :destroy,
|
|
15
|
+
inverse_of: :tenant
|
|
16
|
+
|
|
17
|
+
base.after_create :access_grant_invoke_on_tenant_created
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Grant the Owner role for this tenant to +user+.
|
|
21
|
+
#
|
|
22
|
+
# @param user [Object] host user with +access_grant :user+
|
|
23
|
+
# @return [AccessGrant::Role]
|
|
24
|
+
# @raise [AccessGrant::Error]
|
|
25
|
+
# @see AccessGrant::Owner.grant_owner!
|
|
26
|
+
def grant_owner!(user)
|
|
27
|
+
Owner.grant_owner!(user, tenant: self)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Revoke Owner from +user+ for this tenant.
|
|
31
|
+
#
|
|
32
|
+
# @param user [Object]
|
|
33
|
+
# @return [void]
|
|
34
|
+
# @raise [AccessGrant::Error] last Owner cannot be revoked
|
|
35
|
+
# @see AccessGrant::Owner.revoke_owner!
|
|
36
|
+
def revoke_owner!(user)
|
|
37
|
+
Owner.revoke_owner!(user, tenant: self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def access_grant_invoke_on_tenant_created
|
|
43
|
+
callback = AccessGrant.config.on_tenant_created
|
|
44
|
+
callback&.call(self)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccessGrant
|
|
4
|
+
# Mixed into the host user model via +access_grant :user+.
|
|
5
|
+
#
|
|
6
|
+
# Provides HABTM +:roles+ and {#permitted?}.
|
|
7
|
+
module User
|
|
8
|
+
# @api private
|
|
9
|
+
def self.included(base)
|
|
10
|
+
configure_roles_association(base)
|
|
11
|
+
base.class_eval do
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def access_grant_ensure_can_remove_role(role)
|
|
15
|
+
AccessGrant::Owner.ensure_can_remove_assignment!(role)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.configure_roles_association(base)
|
|
21
|
+
base.has_and_belongs_to_many :roles,
|
|
22
|
+
class_name: "AccessGrant::Role",
|
|
23
|
+
join_table: AccessGrant.config.tables.fetch(:user_roles),
|
|
24
|
+
foreign_key: :user_id,
|
|
25
|
+
association_foreign_key: :role_id,
|
|
26
|
+
before_remove: :access_grant_ensure_can_remove_role
|
|
27
|
+
end
|
|
28
|
+
private_class_method :configure_roles_association
|
|
29
|
+
|
|
30
|
+
# Capability check: does this user have +key+ via any role in scope?
|
|
31
|
+
#
|
|
32
|
+
# Multi-tenant (+tenant_class+ set): +tenant:+ is required.
|
|
33
|
+
# Single-tenant: omit +tenant:+ (raises if supplied).
|
|
34
|
+
#
|
|
35
|
+
# Unknown or malformed keys raise in all Owner modes (including bypass).
|
|
36
|
+
# With +:bypass+ / +:both+, having the Owner role short-circuits to +true+
|
|
37
|
+
# after the key is validated.
|
|
38
|
+
#
|
|
39
|
+
# @param key [String, Symbol] +resource.action+
|
|
40
|
+
# @param tenant [Object, nil] tenant record responding to +id+
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
# @raise [AccessGrant::Error] malformed key, unknown key, or bad tenant args
|
|
43
|
+
def permitted?(key, tenant: nil)
|
|
44
|
+
normalized = PermissionKey.normalize!(key)
|
|
45
|
+
|
|
46
|
+
raise Error, "Unknown permission key: #{normalized.inspect}" unless Permission.exists?(key: normalized)
|
|
47
|
+
|
|
48
|
+
scoped_roles = roles_for_tenant(tenant)
|
|
49
|
+
return true if owner_bypass?(scoped_roles)
|
|
50
|
+
|
|
51
|
+
scoped_roles.joins(:permissions).merge(Permission.where(key: normalized)).exists?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def roles_for_tenant(tenant)
|
|
57
|
+
if multi_tenant?
|
|
58
|
+
raise Error, "tenant: is required in multi-tenant mode" if tenant.nil?
|
|
59
|
+
|
|
60
|
+
roles.where(tenant_id: tenant.id)
|
|
61
|
+
else
|
|
62
|
+
raise Error, "tenant: must not be supplied in single-tenant mode" unless tenant.nil?
|
|
63
|
+
|
|
64
|
+
roles.where(tenant_id: nil)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def owner_bypass?(scoped_roles)
|
|
69
|
+
mode = AccessGrant.config.owner_role
|
|
70
|
+
return false unless %i[bypass both].include?(mode)
|
|
71
|
+
|
|
72
|
+
owner_name = AccessGrant.config.owner_role_name.to_s
|
|
73
|
+
scoped_roles.where("LOWER(name) = ?", owner_name.downcase).exists?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def multi_tenant?
|
|
77
|
+
tenant_class = AccessGrant.config.tenant_class
|
|
78
|
+
!(tenant_class.nil? || tenant_class.to_s.empty?)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Extends +ActiveRecord::Base+ with {#access_grant}.
|
|
83
|
+
module ModelDsl
|
|
84
|
+
# Declare participation in AccessGrant.
|
|
85
|
+
#
|
|
86
|
+
# @param role [Symbol] +:user+ or +:tenant+
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [ArgumentError]
|
|
89
|
+
def access_grant(role)
|
|
90
|
+
case role
|
|
91
|
+
when :user
|
|
92
|
+
include AccessGrant::User
|
|
93
|
+
when :tenant
|
|
94
|
+
include AccessGrant::Tenant
|
|
95
|
+
else
|
|
96
|
+
raise ArgumentError, "Unknown access_grant role: #{role.inspect}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
ActiveRecord::Base.extend(AccessGrant::ModelDsl)
|
data/lib/access_grant.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
require_relative "access_grant/version"
|
|
6
|
+
require_relative "access_grant/configuration"
|
|
7
|
+
require_relative "access_grant/permission_key"
|
|
8
|
+
require_relative "access_grant/catalog"
|
|
9
|
+
require_relative "access_grant/sync"
|
|
10
|
+
require_relative "access_grant/owner"
|
|
11
|
+
require_relative "access_grant/recovery"
|
|
12
|
+
require_relative "access_grant/tenant"
|
|
13
|
+
require_relative "access_grant/user"
|
|
14
|
+
require_relative "access_grant/controller_methods"
|
|
15
|
+
|
|
16
|
+
# Database-backed, per-tenant roles and permissions for Rails.
|
|
17
|
+
#
|
|
18
|
+
# Configure once in an initializer, declare a permission catalog in code,
|
|
19
|
+
# sync into the DB, then check capabilities with {AccessGrant::User#permitted?}.
|
|
20
|
+
#
|
|
21
|
+
# @example Boot configuration
|
|
22
|
+
# AccessGrant.configure do |config|
|
|
23
|
+
# config.tenant_class = "Organization"
|
|
24
|
+
# config.user_class = "User"
|
|
25
|
+
# config.owner_role = :protected
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @see AccessGrant::Configuration
|
|
29
|
+
# @see docs/architecture.md
|
|
30
|
+
module AccessGrant
|
|
31
|
+
# Base error for gem failures (unknown keys, Owner rules, etc.).
|
|
32
|
+
class Error < StandardError; end
|
|
33
|
+
|
|
34
|
+
# Raised by the controller authorize hook when the request is denied
|
|
35
|
+
# (or when +permitted?+ fails with {Error} at the HTTP edge).
|
|
36
|
+
class NotAuthorizedError < Error; end
|
|
37
|
+
|
|
38
|
+
# @return [AccessGrant::Configuration] shared configuration singleton
|
|
39
|
+
def self.config = @config ||= Configuration.new
|
|
40
|
+
|
|
41
|
+
# Yields {#config}, then applies table names / tenant association.
|
|
42
|
+
#
|
|
43
|
+
# @yieldparam config [AccessGrant::Configuration]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def self.configure
|
|
46
|
+
yield(config)
|
|
47
|
+
apply_configuration!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Replace configuration with defaults and re-apply model wiring.
|
|
51
|
+
# Intended for tests.
|
|
52
|
+
#
|
|
53
|
+
# @return [AccessGrant::Configuration]
|
|
54
|
+
def self.reset_config!
|
|
55
|
+
@config = Configuration.new
|
|
56
|
+
apply_configuration!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [AccessGrant::Catalog] in-memory permission catalog
|
|
60
|
+
def self.catalog = @catalog ||= Catalog.new
|
|
61
|
+
|
|
62
|
+
# Clear the in-memory catalog (tests).
|
|
63
|
+
#
|
|
64
|
+
# @return [AccessGrant::Catalog]
|
|
65
|
+
def self.reset_catalog! = @catalog = Catalog.new
|
|
66
|
+
|
|
67
|
+
# Replace the catalog by evaluating a DSL block (typically from
|
|
68
|
+
# +config/access_grant/permissions.rb+).
|
|
69
|
+
#
|
|
70
|
+
# @yield DSL methods such as +resource+, +action+, +category+, +permission+
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @see AccessGrant::Catalog::DSL
|
|
73
|
+
def self.permissions(&) = catalog.replace(&)
|
|
74
|
+
|
|
75
|
+
# Grant the Owner role (single-tenant or with +tenant:+). Prefer
|
|
76
|
+
# +tenant.grant_owner!(user)+ in multi-tenant apps.
|
|
77
|
+
#
|
|
78
|
+
# @param user [Object] host user record with +access_grant :user+
|
|
79
|
+
# @param tenant [Object, nil] required when +tenant_class+ is set
|
|
80
|
+
# @return [AccessGrant::Role] the Owner role
|
|
81
|
+
# @raise [AccessGrant::Error] when Owner is +:none+ or tenant args are wrong
|
|
82
|
+
def self.grant_owner!(user, tenant: nil)
|
|
83
|
+
Owner.grant_owner!(user, tenant: tenant)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Revoke the Owner role from +user+. Fails if this would remove the last
|
|
87
|
+
# Owner assignment for the scope.
|
|
88
|
+
#
|
|
89
|
+
# @param user [Object]
|
|
90
|
+
# @param tenant [Object, nil]
|
|
91
|
+
# @return [void]
|
|
92
|
+
# @raise [AccessGrant::Error]
|
|
93
|
+
def self.revoke_owner!(user, tenant: nil)
|
|
94
|
+
Owner.revoke_owner!(user, tenant: tenant)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Sync model +table_name+ values and optional +Role.belongs_to :tenant+
|
|
98
|
+
# from the current {#config}. Called automatically from {#configure}.
|
|
99
|
+
#
|
|
100
|
+
# @return [void]
|
|
101
|
+
def self.apply_configuration!
|
|
102
|
+
Permission.table_name = config.tables.fetch(:permissions)
|
|
103
|
+
Role.table_name = config.tables.fetch(:roles)
|
|
104
|
+
RolePermission.table_name = config.tables.fetch(:role_permissions)
|
|
105
|
+
|
|
106
|
+
tenant_class = config.tenant_class
|
|
107
|
+
|
|
108
|
+
if tenant_class.nil? || tenant_class.to_s.empty?
|
|
109
|
+
Role._reflections.delete(:tenant)
|
|
110
|
+
Role.clear_reflections_cache
|
|
111
|
+
else
|
|
112
|
+
Role.belongs_to :tenant,
|
|
113
|
+
class_name: tenant_class.to_s,
|
|
114
|
+
foreign_key: :tenant_id,
|
|
115
|
+
optional: true,
|
|
116
|
+
inverse_of: :roles
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
require_relative "access_grant/models/permission"
|
|
122
|
+
require_relative "access_grant/models/role_permission"
|
|
123
|
+
require_relative "access_grant/models/role"
|
|
124
|
+
|
|
125
|
+
require_relative "access_grant/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module AccessGrant
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Creates AccessGrant core tables migration (short default names)"
|
|
13
|
+
|
|
14
|
+
def copy_migration
|
|
15
|
+
migration_template(
|
|
16
|
+
"create_access_grant_tables.rb.tt",
|
|
17
|
+
File.join(db_migrate_path, "create_access_grant_tables.rb")
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
# Short default names. If setup chose access_grant_* tables, edit these
|
|
6
|
+
# names to match config.tables before migrating.
|
|
7
|
+
create_table :permissions do |t|
|
|
8
|
+
t.string :key, null: false
|
|
9
|
+
t.text :description
|
|
10
|
+
t.string :category
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
# permitted? / sync upsert by key
|
|
14
|
+
add_index :permissions, :key, unique: true
|
|
15
|
+
# Search/group by model/controller (category defaults to resource name,
|
|
16
|
+
# e.g. "invoices") — Permission.by_category / ordered_for_ui
|
|
17
|
+
add_index :permissions, %i[category key]
|
|
18
|
+
|
|
19
|
+
create_table :roles do |t|
|
|
20
|
+
t.string :name, null: false
|
|
21
|
+
t.text :description
|
|
22
|
+
t.bigint :tenant_id # nullable for single-tenant / global roles
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
# list roles for a tenant + unique name per tenant (case-sensitive at DB;
|
|
26
|
+
# AR validates case-insensitively — expression unique index is adapter-specific)
|
|
27
|
+
add_index :roles, %i[tenant_id name], unique: true
|
|
28
|
+
# Owner / Recovery lookups: WHERE tenant_id = ? AND LOWER(name) = ?
|
|
29
|
+
add_index :roles, :name
|
|
30
|
+
|
|
31
|
+
create_table :role_permissions do |t|
|
|
32
|
+
t.references :role, null: false, foreign_key: { to_table: :roles }
|
|
33
|
+
t.references :permission, null: false, foreign_key: { to_table: :permissions }
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
# t.references already indexes role_id and permission_id individually
|
|
37
|
+
add_index :role_permissions, %i[role_id permission_id], unique: true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module AccessGrant
|
|
7
|
+
module Generators
|
|
8
|
+
class SetupGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Configures AccessGrant (initializer, catalog, user_roles, model patches)"
|
|
13
|
+
|
|
14
|
+
class_option :multi_tenant, type: :boolean, default: nil,
|
|
15
|
+
desc: "Multi-tenant install (roles scoped by tenant)"
|
|
16
|
+
class_option :single_tenant, type: :boolean, default: nil,
|
|
17
|
+
desc: "Single-tenant install (no tenant_class)"
|
|
18
|
+
class_option :tenant, type: :string, default: "Organization",
|
|
19
|
+
desc: "Tenant model class name"
|
|
20
|
+
class_option :user, type: :string, default: "User",
|
|
21
|
+
desc: "User model class name"
|
|
22
|
+
class_option :owner_role, type: :string, default: "protected",
|
|
23
|
+
desc: "Owner mode: protected, bypass, both, or none"
|
|
24
|
+
class_option :tables, type: :string, default: "auto",
|
|
25
|
+
desc: "Table naming: auto, simple, or prefixed"
|
|
26
|
+
|
|
27
|
+
SHORT_TABLES = {
|
|
28
|
+
roles: "roles",
|
|
29
|
+
permissions: "permissions",
|
|
30
|
+
role_permissions: "role_permissions",
|
|
31
|
+
user_roles: "user_roles"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
PREFIXED_TABLES = {
|
|
35
|
+
roles: "access_grant_roles",
|
|
36
|
+
permissions: "access_grant_permissions",
|
|
37
|
+
role_permissions: "access_grant_role_permissions",
|
|
38
|
+
user_roles: "access_grant_user_roles"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
def resolve_options!
|
|
42
|
+
@multi_tenant = multi_tenant_install?
|
|
43
|
+
@tenant_class = @multi_tenant ? options[:tenant] : nil
|
|
44
|
+
@user_class = options[:user]
|
|
45
|
+
@owner_role = options[:owner_role].to_sym
|
|
46
|
+
@tables = resolve_tables!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_initializer
|
|
50
|
+
template "access_grant.rb.tt", "config/initializers/access_grant.rb"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create_permissions_catalog
|
|
54
|
+
template "permissions.rb.tt", "config/access_grant/permissions.rb"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_roles_config
|
|
58
|
+
template "roles.rb.tt", "config/access_grant/roles.rb"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_user_roles_migration
|
|
62
|
+
@user_roles_table = @tables.fetch(:user_roles)
|
|
63
|
+
@roles_table = @tables.fetch(:roles)
|
|
64
|
+
migration_template(
|
|
65
|
+
"create_access_grant_user_roles.rb.tt",
|
|
66
|
+
File.join(db_migrate_path, "create_access_grant_user_roles.rb")
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def patch_models
|
|
71
|
+
inject_access_grant(@tenant_class, :tenant) if @multi_tenant
|
|
72
|
+
inject_access_grant(@user_class, :user)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_deploy_reminder
|
|
76
|
+
say ""
|
|
77
|
+
say "IMPORTANT — catalog sync is not a migration.", :yellow
|
|
78
|
+
say "After migrate, and on every deploy that may change permissions.rb, run:"
|
|
79
|
+
say " bundle exec rake access_grant:sync_permissions", :green
|
|
80
|
+
say "Wire this into your release process (Kamal / Heroku release / Capistrano)."
|
|
81
|
+
say ""
|
|
82
|
+
return unless @tables != SHORT_TABLES
|
|
83
|
+
|
|
84
|
+
say "Table names differ from access_grant:install defaults (short names).", :yellow
|
|
85
|
+
say "Edit the install migration's table names to match config.tables before db:migrate.", :yellow
|
|
86
|
+
say ""
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def multi_tenant_install?
|
|
92
|
+
return true if options[:multi_tenant]
|
|
93
|
+
return false if options[:single_tenant]
|
|
94
|
+
return yes?("Multi-tenant install? (y/n)") if options[:multi_tenant].nil? && options[:single_tenant].nil?
|
|
95
|
+
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolve_tables!
|
|
100
|
+
strategy = options[:tables].to_s
|
|
101
|
+
case strategy
|
|
102
|
+
when "prefixed"
|
|
103
|
+
PREFIXED_TABLES.dup
|
|
104
|
+
when "simple"
|
|
105
|
+
fail_if_short_taken!
|
|
106
|
+
SHORT_TABLES.dup
|
|
107
|
+
when "auto"
|
|
108
|
+
if short_names_taken?
|
|
109
|
+
say_status :collision, "short table/model names taken → access_grant_*", :yellow
|
|
110
|
+
PREFIXED_TABLES.dup
|
|
111
|
+
else
|
|
112
|
+
unless connection_available?
|
|
113
|
+
say_status :info,
|
|
114
|
+
"no DB connection — assuming short table names are free " \
|
|
115
|
+
"(--tables=auto); use --tables=prefixed if they may collide",
|
|
116
|
+
:blue
|
|
117
|
+
end
|
|
118
|
+
SHORT_TABLES.dup
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
raise Thor::Error, "Unknown --tables=#{strategy.inspect} (use auto|simple|prefixed)"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def short_names_taken?
|
|
126
|
+
SHORT_TABLES.each_value.any? { |name| table_exists_safe?(name) } ||
|
|
127
|
+
%w[Role Permission].any? { |const| Object.const_defined?(const, false) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fail_if_short_taken!
|
|
131
|
+
return unless short_names_taken?
|
|
132
|
+
|
|
133
|
+
raise Thor::Error,
|
|
134
|
+
"Short table/model names are taken (roles/permissions/role_permissions/" \
|
|
135
|
+
"user_roles or top-level Role/Permission). " \
|
|
136
|
+
"Use --tables=auto or --tables=prefixed instead of --tables=simple."
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def connection_available?
|
|
140
|
+
defined?(ActiveRecord::Base) &&
|
|
141
|
+
ActiveRecord::Base.connected? &&
|
|
142
|
+
ActiveRecord::Base.connection
|
|
143
|
+
rescue StandardError
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def table_exists_safe?(name)
|
|
148
|
+
return false unless connection_available?
|
|
149
|
+
|
|
150
|
+
ActiveRecord::Base.connection.table_exists?(name)
|
|
151
|
+
rescue StandardError
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def inject_access_grant(class_name, role)
|
|
156
|
+
path = model_path_for(class_name)
|
|
157
|
+
unless path && File.exist?(path)
|
|
158
|
+
say_status :warn, "could not find model file for #{class_name} — add `access_grant :#{role}` manually",
|
|
159
|
+
:yellow
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
marker = "access_grant :#{role}"
|
|
164
|
+
if File.read(path).include?(marker)
|
|
165
|
+
say_status :identical, "#{path} already has #{marker}", :blue
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
inject_into_class(path, class_name.to_s.demodulize, " #{marker}\n")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def model_path_for(class_name)
|
|
173
|
+
return nil if class_name.nil? || class_name.to_s.empty?
|
|
174
|
+
|
|
175
|
+
relative = class_name.to_s.underscore
|
|
176
|
+
candidates = [
|
|
177
|
+
File.join(destination_root, "app/models/#{relative}.rb"),
|
|
178
|
+
File.join(destination_root, "app/models/#{relative.split('/').last}.rb")
|
|
179
|
+
]
|
|
180
|
+
candidates.find { |p| File.exist?(p) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Template helpers
|
|
184
|
+
def multi_tenant? = @multi_tenant
|
|
185
|
+
attr_reader :tenant_class, :user_class, :owner_role, :tables
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|