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,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)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessGrant
4
+ VERSION = "1.0.0"
5
+ end
@@ -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