rails_iam 0.1.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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/CONTRIBUTING.md +145 -0
  4. data/LICENSE +20 -0
  5. data/README.md +192 -0
  6. data/Rakefile +14 -0
  7. data/app/controllers/concerns/rails_iam/auth_concern.rb +60 -0
  8. data/app/controllers/concerns/rails_iam/authentication/authenticatable.rb +44 -0
  9. data/app/controllers/concerns/rails_iam/authorization/authorizable.rb +170 -0
  10. data/app/controllers/rails_iam/application_controller.rb +7 -0
  11. data/app/controllers/rails_iam/auth_controller.rb +113 -0
  12. data/app/helpers/rails_iam/config_loader.rb +71 -0
  13. data/app/models/concerns/rails_iam/auditable.rb +57 -0
  14. data/app/models/concerns/rails_iam/user_concern.rb +59 -0
  15. data/app/models/rails_iam/application_record.rb +8 -0
  16. data/app/models/rails_iam/permission.rb +14 -0
  17. data/app/models/rails_iam/refresh_token.rb +7 -0
  18. data/app/models/rails_iam/role.rb +14 -0
  19. data/app/models/rails_iam/role_permission.rb +20 -0
  20. data/app/models/rails_iam/user.rb +16 -0
  21. data/app/models/rails_iam/user_denied_permission.rb +12 -0
  22. data/app/models/rails_iam/user_permission.rb +12 -0
  23. data/app/models/rails_iam/user_role.rb +12 -0
  24. data/app/services/application_service.rb +4 -0
  25. data/app/services/rails_iam/authentication/authenticated_user.rb +13 -0
  26. data/app/services/rails_iam/authentication/authenticator.rb +39 -0
  27. data/app/services/rails_iam/authentication/current.rb +9 -0
  28. data/app/services/rails_iam/authentication/exceptions/authentication_error.rb +10 -0
  29. data/app/services/rails_iam/authentication/jwt_decoder.rb +38 -0
  30. data/app/services/rails_iam/authentication/jwt_encoder.rb +45 -0
  31. data/app/services/rails_iam/authentication/login.rb +67 -0
  32. data/app/services/rails_iam/authentication/logout.rb +35 -0
  33. data/app/services/rails_iam/authentication/refresh_token_encoder.rb +11 -0
  34. data/app/services/rails_iam/authentication/skip_authentication_rule.rb +20 -0
  35. data/app/services/rails_iam/authentication/token_extractor.rb +64 -0
  36. data/app/services/rails_iam/authorization/authorization_context.rb +41 -0
  37. data/app/services/rails_iam/authorization/authorization_rule.rb +38 -0
  38. data/app/services/rails_iam/authorization/authorizer.rb +35 -0
  39. data/app/services/rails_iam/authorization/cache.rb +39 -0
  40. data/app/services/rails_iam/authorization/denied_permission_checker.rb +33 -0
  41. data/app/services/rails_iam/authorization/exceptions/authorization_error.rb +10 -0
  42. data/app/services/rails_iam/authorization/exceptions/missing_permission_error.rb +35 -0
  43. data/app/services/rails_iam/authorization/exceptions/missing_role_error.rb +35 -0
  44. data/app/services/rails_iam/authorization/permission_checker.rb +42 -0
  45. data/app/services/rails_iam/authorization/permission_resolver.rb +70 -0
  46. data/app/services/rails_iam/authorization/permission_sources/role_permission_source.rb +37 -0
  47. data/app/services/rails_iam/authorization/permission_sources/user_denied_permission_source.rb +29 -0
  48. data/app/services/rails_iam/authorization/permission_sources/user_permission_source.rb +28 -0
  49. data/app/services/rails_iam/authorization/resolved_permissions.rb +63 -0
  50. data/app/services/rails_iam/authorization/role_checker.rb +35 -0
  51. data/app/services/rails_iam/authorization/role_resolver.rb +30 -0
  52. data/app/services/rails_iam/authorization/skip_authorization_rule.rb +19 -0
  53. data/app/services/rails_iam/rails_iam_service.rb +11 -0
  54. data/config/routes.rb +25 -0
  55. data/lib/generators/rails_iam/install_generator.rb +49 -0
  56. data/lib/generators/rails_iam/templates/create_rails_iam_tables.rb.erb +119 -0
  57. data/lib/generators/rails_iam/templates/initializer.rb +162 -0
  58. data/lib/rails_iam/config/authentication.rb +65 -0
  59. data/lib/rails_iam/config/authorization.rb +36 -0
  60. data/lib/rails_iam/config/jwt.rb +27 -0
  61. data/lib/rails_iam/configuration.rb +30 -0
  62. data/lib/rails_iam/controller_dsl.rb +41 -0
  63. data/lib/rails_iam/engine.rb +34 -0
  64. data/lib/rails_iam/model_dsl.rb +13 -0
  65. data/lib/rails_iam/version.rb +5 -0
  66. data/lib/rails_iam.rb +27 -0
  67. data/lib/tasks/rails_iam_tasks.rake +6 -0
  68. data/rails_iam.gemspec +46 -0
  69. metadata +161 -0
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authentication
5
+ class Logout < RailsIam::RailsIamService
6
+ def initialize(refresh_token: nil)
7
+ super()
8
+
9
+ @refresh_token = refresh_token
10
+ end
11
+
12
+ def call
13
+ invalidate_refresh_token
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :refresh_token
19
+
20
+ def invalidate_refresh_token
21
+ return unless refresh_token
22
+
23
+ case session_strategy
24
+ when :single
25
+ refresh_token.user.refresh_tokens.delete_all
26
+ when :multi
27
+ refresh_token.destroy
28
+
29
+ else
30
+ raise RailsIam::Authentication::Exceptions::AuthenticationError, session_strategy_error
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authentication
5
+ class RefreshTokenEncoder
6
+ def self.call
7
+ SecureRandom.hex(64)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authentication
5
+ SkipAuthenticationRule = Data.define(
6
+ :only,
7
+ :except,
8
+ ) do
9
+ def matches?(action)
10
+ action = action.to_sym
11
+
12
+ return Array(only).map(&:to_sym).include?(action) if only.present?
13
+
14
+ return Array(except).map(&:to_sym).exclude?(action) if except.present?
15
+
16
+ true
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authentication
5
+ class CookieExtractor < RailsIam::RailsIamService
6
+ def initialize(request)
7
+ super()
8
+ @request = request
9
+ end
10
+
11
+ def call
12
+ request.cookie_jar.encrypted[access_token_key]
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :request
18
+ end
19
+
20
+ class BearerExtractor < RailsIam::RailsIamService
21
+ def initialize(request)
22
+ super()
23
+ @request = request
24
+ end
25
+
26
+ def call
27
+ authorization = request.headers["Authorization"]
28
+ return if authorization.blank?
29
+
30
+ scheme, token = authorization.split(" ", 2)
31
+
32
+ scheme == "Bearer" ? token : nil
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :request
38
+ end
39
+
40
+ class TokenExtractor < RailsIam::RailsIamService
41
+ def initialize(request)
42
+ super()
43
+ @request = request
44
+ end
45
+
46
+ def call
47
+ extractor.call(request)
48
+ end
49
+
50
+ private
51
+
52
+ def extractor
53
+ # Explicitly pick the extractor based on the incoming request signature
54
+ if authentication_strategies.include?(:bearer) && request.headers["Authorization"]&.start_with?("Bearer ")
55
+ BearerExtractor
56
+ else
57
+ CookieExtractor
58
+ end
59
+ end
60
+
61
+ attr_reader :request
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ class AuthorizationContext
6
+ attr_reader :user,
7
+ :resolved_permissions,
8
+ :roles,
9
+ :required_roles,
10
+ :required_permissions,
11
+ :inferred_permission,
12
+ :role_match,
13
+ :permission_match
14
+
15
+ def initialize(
16
+ user:,
17
+ required_roles: [],
18
+ required_permissions: [],
19
+ inferred_permission: nil,
20
+ role_match: :any,
21
+ permission_match: :all
22
+ )
23
+ @user = user
24
+ @roles = RoleResolver.call(user)
25
+ @resolved_permissions = PermissionResolver.call(user)
26
+ @required_roles = required_roles.map(&:to_s)
27
+ @required_permissions = required_permissions.map(&:to_s)
28
+ @inferred_permission = inferred_permission
29
+ @role_match = role_match
30
+ @permission_match = permission_match
31
+ end
32
+
33
+ def permissions_for_check
34
+ return required_permissions if required_permissions.any?
35
+ return [inferred_permission] if inferred_permission.present?
36
+
37
+ []
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ AuthorizationRule = Data.define(
6
+ :roles,
7
+ :permissions,
8
+ :role_match,
9
+ :permission_match,
10
+ :only,
11
+ :except,
12
+ ) do
13
+ def matches?(action)
14
+ action = action.to_sym
15
+
16
+ return Array(only).map(&:to_sym).include?(action) if only.present?
17
+
18
+ return Array(except).map(&:to_sym).exclude?(action) if except.present?
19
+
20
+ true
21
+ end
22
+
23
+ def authorizer_arguments(inferred_permission: nil)
24
+ {
25
+ required_roles: roles,
26
+ required_permissions: permissions,
27
+ inferred_permission: inferred_permission,
28
+ role_match: role_match,
29
+ permission_match: permission_match,
30
+ }
31
+ end
32
+
33
+ def valid?
34
+ roles.any? || permissions.any?
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ class Authorizer < RailsIam::RailsIamService
6
+ def initialize(
7
+ user:,
8
+ required_roles: [],
9
+ required_permissions: [],
10
+ inferred_permission: nil,
11
+ role_match: :any,
12
+ permission_match: :all
13
+ )
14
+ super()
15
+ @context =
16
+ AuthorizationContext.new(
17
+ user: user,
18
+ required_roles: required_roles,
19
+ required_permissions: required_permissions,
20
+ inferred_permission: inferred_permission,
21
+ role_match: role_match,
22
+ permission_match: permission_match,
23
+ )
24
+ end
25
+
26
+ def call
27
+ DeniedPermissionChecker.call(@context)
28
+ RoleChecker.call(@context)
29
+ PermissionChecker.call(@context)
30
+
31
+ true # success is the absence of an exception
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ class Cache
6
+ ROLES = :roles
7
+ PERMISSIONS = :permissions
8
+
9
+ class << self
10
+ def read_or_write(namespace, user_id, &block)
11
+ cache.fetch(
12
+ cache_key(namespace, user_id),
13
+ expires_in: RailsIam.configuration.authorization.cache_ttl,
14
+ &block
15
+ )
16
+ end
17
+
18
+ def delete(namespace, user_id)
19
+ cache.delete(cache_key(namespace, user_id))
20
+ end
21
+
22
+ def delete_all(user_id)
23
+ delete(Cache::ROLES, user_id)
24
+ delete(Cache::PERMISSIONS, user_id)
25
+ end
26
+
27
+ private
28
+
29
+ def cache
30
+ RailsIam.configuration.authorization.cache_store || Rails.cache
31
+ end
32
+
33
+ def cache_key(namespace, user_id)
34
+ "rails_iam:#{namespace}:#{user_id}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ class DeniedPermissionChecker < RailsIam::RailsIamService
6
+ def initialize(context)
7
+ super()
8
+ @context = context
9
+ end
10
+
11
+ def call
12
+ return true if denied_permissions.empty?
13
+
14
+ raise RailsIam::Authorization::Exceptions::MissingPermissionError.new(
15
+ required: denied_permissions,
16
+ )
17
+ end
18
+
19
+ private
20
+
21
+ def denied_permissions
22
+ permissions = @context.permissions_for_check
23
+
24
+ denied =
25
+ @context.resolved_permissions.denied_codes
26
+
27
+ permissions.select do |permission|
28
+ denied.include?(permission)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ module Exceptions
6
+ class AuthorizationError < StandardError
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ module Exceptions
6
+ class MissingPermissionError < AuthorizationError
7
+ include RailsIam::ConfigLoader
8
+
9
+ def initialize(required:)
10
+ formatter = missing_permission_error
11
+
12
+ message = case formatter
13
+ # 1. If the user configured a plain String, use it directly
14
+ when String
15
+ formatter
16
+ # 2. If it's a Proc/Lambda, inspect its argument requirements
17
+ when Proc
18
+ if formatter.arity.zero?
19
+ # The user defined a lambda with no parameters: e.g., -> { "Access Denied" }
20
+ formatter.call
21
+ else
22
+ # The user defined a parameterized lambda: e.g., ->(required:, actual:) { ... }
23
+ formatter.call(required: required)
24
+ end
25
+ # 3. Fallback safeguard if config is empty or invalid type
26
+ else
27
+ "Missing permission(s): #{required.join(', ')}"
28
+ end
29
+
30
+ super(message)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ module Exceptions
6
+ class MissingRoleError < AuthorizationError
7
+ include RailsIam::ConfigLoader
8
+
9
+ def initialize(required:, actual:)
10
+ formatter = missing_role_error
11
+
12
+ message = case formatter
13
+ # 1. If the user configured a plain String, use it directly
14
+ when String
15
+ formatter
16
+ # 2. If it's a Proc/Lambda, inspect its argument requirements
17
+ when Proc
18
+ if formatter.arity.zero?
19
+ # The user defined a lambda with no parameters: e.g., -> { "Access Denied" }
20
+ formatter.call
21
+ else
22
+ # The user defined a parameterized lambda: e.g., ->(required:, actual:) { ... }
23
+ formatter.call(required: required, actual: actual)
24
+ end
25
+ # 3. Fallback safeguard if config is empty or invalid type
26
+ else
27
+ "Required role: #{required.join(', ')}, actual: #{actual.join(', ')}"
28
+ end
29
+
30
+ super(message)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ class PermissionChecker < RailsIam::RailsIamService
6
+ def initialize(context)
7
+ super()
8
+ @context = context
9
+ end
10
+
11
+ def call
12
+ return true if permissions.empty?
13
+
14
+ success =
15
+ case @context.permission_match
16
+ when :any
17
+ permissions.any? do |permission|
18
+ @context.resolved_permissions.include?(permission)
19
+ end
20
+
21
+ when :all
22
+ permissions.all? do |permission|
23
+ @context.resolved_permissions.include?(permission)
24
+ end
25
+
26
+ else
27
+ raise ArgumentError, "Unknown permission_match #{@context.permission_match}"
28
+ end
29
+
30
+ return true if success
31
+
32
+ raise RailsIam::Authorization::Exceptions::MissingPermissionError.new(
33
+ required: permissions,
34
+ )
35
+ end
36
+
37
+ def permissions
38
+ @context.permissions_for_check
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ PermissionMetadata = Data.define(
6
+ :source,
7
+ :role,
8
+ )
9
+
10
+ class PermissionResolver < RailsIam::RailsIamService
11
+ ALLOW_SOURCES = [
12
+ Authorization::PermissionSources::RolePermissionSource,
13
+ Authorization::PermissionSources::UserPermissionSource,
14
+ ].freeze
15
+
16
+ DENY_SOURCES = [
17
+ Authorization::PermissionSources::UserDeniedPermissionSource,
18
+ ].freeze
19
+
20
+ def initialize(user)
21
+ super()
22
+
23
+ @user = user
24
+ end
25
+
26
+ def call
27
+ Cache.read_or_write(:permissions, user.id) do
28
+ resolve
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :user
35
+
36
+ def resolve
37
+ allowed_codes = Set.new
38
+ denied_codes = Set.new
39
+ metadata = {}
40
+
41
+ # Resolve allowed permissions
42
+ ALLOW_SOURCES.each do |source|
43
+ source.call(@user).each do |row|
44
+ allowed_codes << row[:code]
45
+
46
+ metadata[row[:code]] ||= []
47
+
48
+ metadata[row[:code]] << PermissionMetadata.new(
49
+ source: row[:source],
50
+ role: row[:role],
51
+ )
52
+ end
53
+ end
54
+
55
+ # Resolve denied permissions
56
+ DENY_SOURCES.each do |source|
57
+ source.call(@user).each do |row|
58
+ denied_codes << row[:code]
59
+ end
60
+ end
61
+
62
+ ResolvedPermissions.new(
63
+ allowed_codes: allowed_codes,
64
+ denied_codes: denied_codes,
65
+ metadata: metadata,
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ module PermissionSources
6
+ class RolePermissionSource < RailsIam::RailsIamService
7
+ def initialize(user)
8
+ super()
9
+ @user = user
10
+ end
11
+
12
+ def call
13
+ rows = []
14
+
15
+ user
16
+ .roles
17
+ .includes(:permissions)
18
+ .find_each do |role|
19
+ role.permissions.each do |permission|
20
+ rows << {
21
+ code: permission.code,
22
+ source: :role,
23
+ role: role.name,
24
+ }
25
+ end
26
+ end
27
+
28
+ rows
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :user
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ module PermissionSources
6
+ class UserDeniedPermissionSource < RailsIam::RailsIamService
7
+ def initialize(user)
8
+ super()
9
+
10
+ @user = user
11
+ end
12
+
13
+ def call
14
+ user.user_denied_permissions.includes(:permission).map do |record|
15
+ {
16
+ code: record.permission.code,
17
+ source: :user_denied,
18
+ role: nil,
19
+ }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :user
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ module PermissionSources
6
+ class UserPermissionSource < RailsIam::RailsIamService
7
+ def initialize(user)
8
+ super()
9
+
10
+ @user = user
11
+ end
12
+
13
+ def call
14
+ user.user_permissions.includes(:permission).map do |record|
15
+ {
16
+ code: record.permission.code,
17
+ source: :user,
18
+ }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :user
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsIam
4
+ module Authorization
5
+ class ResolvedPermissions
6
+ attr_reader :allowed_codes,
7
+ :denied_codes,
8
+ :metadata
9
+
10
+ def initialize(
11
+ allowed_codes:,
12
+ denied_codes:,
13
+ metadata:
14
+ )
15
+ @allowed_codes = allowed_codes.to_set.freeze
16
+ @denied_codes = denied_codes.to_set.freeze
17
+ @metadata = metadata.freeze
18
+ end
19
+
20
+ def include?(permission)
21
+ permission = permission.to_s
22
+
23
+ return false if denied?(permission)
24
+
25
+ allowed?(permission)
26
+ end
27
+
28
+ def codes
29
+ allowed_codes
30
+ end
31
+
32
+ def effective_codes
33
+ @allowed_codes - @denied_codes
34
+ end
35
+
36
+ def explain(permission)
37
+ metadata.fetch(permission.to_s, [])
38
+ end
39
+
40
+ private
41
+
42
+ def allowed?(permission)
43
+ match?(allowed_codes, permission)
44
+ end
45
+
46
+ def denied?(permission)
47
+ match?(denied_codes, permission)
48
+ end
49
+
50
+ def match?(set, permission)
51
+ return true if set.include?(permission)
52
+
53
+ namespace = permission.split(":").first
54
+
55
+ return true if set.include?("#{namespace}:*")
56
+
57
+ return true if set.include?("*")
58
+
59
+ false
60
+ end
61
+ end
62
+ end
63
+ end