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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.codegraph/.gitignore +5 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +98 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +33 -0
  7. data/CONTRIBUTING.md +99 -0
  8. data/Gemfile +11 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +123 -0
  11. data/Rakefile +12 -0
  12. data/docs/architecture.md +1157 -0
  13. data/docs/proposal.md +143 -0
  14. data/docs/superpowers/plans/2026-09-08-access-grant-v1.md +468 -0
  15. data/docs/superpowers/plans/2026-09-08-gem-release.md +367 -0
  16. data/docs/superpowers/specs/2026-09-05-owner-role-design.md +271 -0
  17. data/docs/superpowers/specs/2026-09-07-proposal-review.md +71 -0
  18. data/docs/superpowers/specs/2026-09-07-usage-scenarios.md +301 -0
  19. data/docs/superpowers/specs/2026-09-08-gem-release-design.md +82 -0
  20. data/lib/access_grant/catalog/dsl.rb +138 -0
  21. data/lib/access_grant/catalog.rb +76 -0
  22. data/lib/access_grant/configuration.rb +55 -0
  23. data/lib/access_grant/controller_methods.rb +104 -0
  24. data/lib/access_grant/models/permission.rb +36 -0
  25. data/lib/access_grant/models/role.rb +152 -0
  26. data/lib/access_grant/models/role_permission.rb +11 -0
  27. data/lib/access_grant/owner.rb +144 -0
  28. data/lib/access_grant/permission_key.rb +29 -0
  29. data/lib/access_grant/railtie.rb +17 -0
  30. data/lib/access_grant/recovery.rb +90 -0
  31. data/lib/access_grant/sync.rb +68 -0
  32. data/lib/access_grant/tenant.rb +47 -0
  33. data/lib/access_grant/user.rb +102 -0
  34. data/lib/access_grant/version.rb +5 -0
  35. data/lib/access_grant.rb +125 -0
  36. data/lib/generators/access_grant/install/install_generator.rb +22 -0
  37. data/lib/generators/access_grant/install/templates/create_access_grant_tables.rb.tt +39 -0
  38. data/lib/generators/access_grant/setup/setup_generator.rb +188 -0
  39. data/lib/generators/access_grant/setup/templates/access_grant.rb.tt +80 -0
  40. data/lib/generators/access_grant/setup/templates/create_access_grant_user_roles.rb.tt +14 -0
  41. data/lib/generators/access_grant/setup/templates/permissions.rb.tt +10 -0
  42. data/lib/generators/access_grant/setup/templates/roles.rb.tt +27 -0
  43. data/lib/tasks/access_grant_tasks.rake +27 -0
  44. 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