decision_agent 0.1.4 → 0.1.6
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 +4 -4
- data/README.md +84 -233
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +46 -10
- data/lib/decision_agent/agent.rb +5 -3
- data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
- data/lib/decision_agent/auth/authenticator.rb +127 -0
- data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
- data/lib/decision_agent/auth/password_reset_token.rb +33 -0
- data/lib/decision_agent/auth/permission.rb +29 -0
- data/lib/decision_agent/auth/permission_checker.rb +43 -0
- data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
- data/lib/decision_agent/auth/rbac_config.rb +51 -0
- data/lib/decision_agent/auth/role.rb +56 -0
- data/lib/decision_agent/auth/session.rb +33 -0
- data/lib/decision_agent/auth/session_manager.rb +57 -0
- data/lib/decision_agent/auth/user.rb +70 -0
- data/lib/decision_agent/context.rb +24 -4
- data/lib/decision_agent/decision.rb +10 -3
- data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
- data/lib/decision_agent/dsl/schema_validator.rb +8 -1
- data/lib/decision_agent/errors.rb +38 -0
- data/lib/decision_agent/evaluation.rb +10 -3
- data/lib/decision_agent/evaluation_validator.rb +8 -13
- data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +17 -5
- data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
- data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
- data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
- data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
- data/lib/decision_agent/testing/test_scenario.rb +42 -0
- data/lib/decision_agent/version.rb +10 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
- data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
- data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
- data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
- data/lib/decision_agent/web/public/app.js +184 -29
- data/lib/decision_agent/web/public/batch_testing.html +640 -0
- data/lib/decision_agent/web/public/index.html +37 -9
- data/lib/decision_agent/web/public/login.html +298 -0
- data/lib/decision_agent/web/public/users.html +679 -0
- data/lib/decision_agent/web/server.rb +873 -7
- data/lib/decision_agent.rb +52 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
- data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
- data/spec/ab_testing/ab_test_manager_spec.rb +282 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
- data/spec/ab_testing/storage/adapter_spec.rb +64 -0
- data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
- data/spec/advanced_operators_spec.rb +1003 -0
- data/spec/agent_spec.rb +40 -0
- data/spec/audit_adapters_spec.rb +18 -0
- data/spec/auth/access_audit_logger_spec.rb +394 -0
- data/spec/auth/authenticator_spec.rb +112 -0
- data/spec/auth/password_reset_spec.rb +294 -0
- data/spec/auth/permission_checker_spec.rb +207 -0
- data/spec/auth/permission_spec.rb +73 -0
- data/spec/auth/rbac_adapter_spec.rb +550 -0
- data/spec/auth/rbac_config_spec.rb +82 -0
- data/spec/auth/role_spec.rb +51 -0
- data/spec/auth/session_manager_spec.rb +172 -0
- data/spec/auth/session_spec.rb +112 -0
- data/spec/auth/user_spec.rb +130 -0
- data/spec/context_spec.rb +43 -0
- data/spec/decision_agent_spec.rb +96 -0
- data/spec/decision_spec.rb +423 -0
- data/spec/dsl/condition_evaluator_spec.rb +774 -0
- data/spec/evaluation_spec.rb +364 -0
- data/spec/evaluation_validator_spec.rb +165 -0
- data/spec/examples.txt +1542 -612
- data/spec/monitoring/metrics_collector_spec.rb +220 -2
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +153 -1
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/performance_optimizations_spec.rb +486 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/testing/batch_test_importer_spec.rb +693 -0
- data/spec/testing/batch_test_runner_spec.rb +307 -0
- data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
- data/spec/testing/test_result_comparator_spec.rb +392 -0
- data/spec/testing/test_scenario_spec.rb +113 -0
- data/spec/versioning/adapter_spec.rb +156 -0
- data/spec/versioning_spec.rb +253 -0
- data/spec/web/middleware/auth_middleware_spec.rb +133 -0
- data/spec/web/middleware/permission_middleware_spec.rb +247 -0
- data/spec/web_ui_rack_spec.rb +1705 -0
- metadata +99 -6
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
class Authenticator
|
|
4
|
+
attr_reader :user_store, :session_manager, :password_reset_manager
|
|
5
|
+
|
|
6
|
+
def initialize(user_store: nil, session_manager: nil, password_reset_manager: nil)
|
|
7
|
+
@user_store = user_store || InMemoryUserStore.new
|
|
8
|
+
@session_manager = session_manager || SessionManager.new
|
|
9
|
+
@password_reset_manager = password_reset_manager || PasswordResetManager.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def login(email, password)
|
|
13
|
+
user = @user_store.find_by_email(email)
|
|
14
|
+
return nil unless user
|
|
15
|
+
return nil unless user.active
|
|
16
|
+
return nil unless user.authenticate(password)
|
|
17
|
+
|
|
18
|
+
@session_manager.create_session(user.id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def logout(token)
|
|
22
|
+
@session_manager.delete_session(token)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def authenticate(token)
|
|
26
|
+
session = @session_manager.get_session(token)
|
|
27
|
+
return nil unless session
|
|
28
|
+
|
|
29
|
+
user = @user_store.find_by_id(session.user_id)
|
|
30
|
+
return nil unless user
|
|
31
|
+
return nil unless user.active
|
|
32
|
+
|
|
33
|
+
{ user: user, session: session }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_user(email:, password:, roles: [])
|
|
37
|
+
user = User.new(email: email, password: password, roles: roles)
|
|
38
|
+
@user_store.save(user)
|
|
39
|
+
user
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find_user(user_id)
|
|
43
|
+
@user_store.find_by_id(user_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def find_user_by_email(email)
|
|
47
|
+
@user_store.find_by_email(email)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def request_password_reset(email)
|
|
51
|
+
user = @user_store.find_by_email(email)
|
|
52
|
+
return nil unless user
|
|
53
|
+
return nil unless user.active
|
|
54
|
+
|
|
55
|
+
# Delete any existing reset tokens for this user
|
|
56
|
+
@password_reset_manager.delete_user_tokens(user.id)
|
|
57
|
+
|
|
58
|
+
# Create a new reset token (expires in 1 hour)
|
|
59
|
+
@password_reset_manager.create_token(user.id, expires_in: 3600)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset_password(token_string, new_password)
|
|
63
|
+
token = @password_reset_manager.get_token(token_string)
|
|
64
|
+
return nil unless token
|
|
65
|
+
|
|
66
|
+
user = @user_store.find_by_id(token.user_id)
|
|
67
|
+
return nil unless user
|
|
68
|
+
return nil unless user.active
|
|
69
|
+
|
|
70
|
+
# Update the password
|
|
71
|
+
user.update_password(new_password)
|
|
72
|
+
@user_store.save(user)
|
|
73
|
+
|
|
74
|
+
# Delete the used token and all other tokens for this user
|
|
75
|
+
@password_reset_manager.delete_user_tokens(user.id)
|
|
76
|
+
|
|
77
|
+
# Invalidate all existing sessions for security
|
|
78
|
+
@session_manager.delete_user_sessions(user.id)
|
|
79
|
+
|
|
80
|
+
user
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# In-memory user store (can be replaced with ActiveRecord adapter later)
|
|
85
|
+
class InMemoryUserStore
|
|
86
|
+
def initialize
|
|
87
|
+
@users = {}
|
|
88
|
+
@users_by_email = {}
|
|
89
|
+
@mutex = Mutex.new
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def save(user)
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
@users[user.id] = user
|
|
95
|
+
@users_by_email[user.email.downcase] = user
|
|
96
|
+
end
|
|
97
|
+
user
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def find_by_id(id)
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
@users[id]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def find_by_email(email)
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
@users_by_email[email.downcase]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def all
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
@users.values.dup
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delete(id)
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
user = @users.delete(id)
|
|
121
|
+
@users_by_email.delete(user.email.downcase) if user
|
|
122
|
+
user
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
class PasswordResetManager
|
|
4
|
+
def initialize
|
|
5
|
+
@tokens = {}
|
|
6
|
+
@mutex = Mutex.new
|
|
7
|
+
@cleanup_interval = 300 # 5 minutes
|
|
8
|
+
@last_cleanup = Time.now
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create_token(user_id, expires_in: 3600)
|
|
12
|
+
token = PasswordResetToken.new(user_id: user_id, expires_in: expires_in)
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
@tokens[token.token] = token
|
|
15
|
+
cleanup_expired_tokens
|
|
16
|
+
end
|
|
17
|
+
token
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get_token(token_string)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
token = @tokens[token_string]
|
|
23
|
+
return nil unless token
|
|
24
|
+
return nil if token.expired?
|
|
25
|
+
|
|
26
|
+
token
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete_token(token_string)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@tokens.delete(token_string)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete_user_tokens(user_id)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@tokens.delete_if { |_token_string, token| token.user_id == user_id }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cleanup_expired_tokens
|
|
43
|
+
now = Time.now
|
|
44
|
+
return if (now - @last_cleanup) < @cleanup_interval
|
|
45
|
+
|
|
46
|
+
@tokens.delete_if { |_token_string, token| token.expired? }
|
|
47
|
+
@last_cleanup = now
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def count
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@tokens.size
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Auth
|
|
5
|
+
class PasswordResetToken
|
|
6
|
+
attr_reader :token, :user_id, :created_at, :expires_at
|
|
7
|
+
|
|
8
|
+
def initialize(user_id:, expires_in: 3600)
|
|
9
|
+
@token = SecureRandom.hex(32)
|
|
10
|
+
@user_id = user_id
|
|
11
|
+
@created_at = Time.now.utc
|
|
12
|
+
@expires_at = @created_at + expires_in
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def expired?
|
|
16
|
+
Time.now.utc > @expires_at
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def valid?
|
|
20
|
+
!expired?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
token: @token,
|
|
26
|
+
user_id: @user_id,
|
|
27
|
+
created_at: @created_at.iso8601,
|
|
28
|
+
expires_at: @expires_at.iso8601
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
class Permission
|
|
4
|
+
PERMISSIONS = {
|
|
5
|
+
read: "Read access to rules and versions",
|
|
6
|
+
write: "Create and modify rules",
|
|
7
|
+
delete: "Delete rules and versions",
|
|
8
|
+
approve: "Approve rule changes",
|
|
9
|
+
deploy: "Deploy rule versions",
|
|
10
|
+
manage_users: "Manage users and roles",
|
|
11
|
+
audit: "Access audit logs"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def all
|
|
16
|
+
PERMISSIONS.keys
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def exists?(permission)
|
|
20
|
+
PERMISSIONS.key?(permission.to_sym)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def description_for(permission)
|
|
24
|
+
PERMISSIONS[permission.to_sym]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
class PermissionChecker
|
|
4
|
+
attr_reader :adapter
|
|
5
|
+
|
|
6
|
+
def initialize(adapter: nil)
|
|
7
|
+
@adapter = adapter || DefaultAdapter.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def can?(user, permission, resource = nil)
|
|
11
|
+
@adapter.can?(user, permission, resource)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def require_permission!(user, permission, resource = nil)
|
|
15
|
+
raise PermissionDeniedError, "User does not have permission: #{permission}" unless can?(user, permission, resource)
|
|
16
|
+
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def has_role?(user, role)
|
|
21
|
+
@adapter.has_role?(user, role)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def require_role!(user, role)
|
|
25
|
+
raise PermissionDeniedError, "User does not have role: #{role}" unless has_role?(user, role)
|
|
26
|
+
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def active?(user)
|
|
31
|
+
@adapter.active?(user)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def user_id(user)
|
|
35
|
+
@adapter.user_id(user)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def user_email(user)
|
|
39
|
+
@adapter.user_email(user)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
# Base adapter interface for RBAC integration
|
|
4
|
+
# Users can extend this to integrate with any authentication/authorization system
|
|
5
|
+
class RbacAdapter
|
|
6
|
+
# Check if a user has a specific permission
|
|
7
|
+
# @param user [Object] The user object from your auth system
|
|
8
|
+
# @param permission [Symbol, String] The permission to check
|
|
9
|
+
# @param resource [Object, nil] Optional resource for resource-level permissions
|
|
10
|
+
# @return [Boolean] true if user has permission, false otherwise
|
|
11
|
+
def can?(user, permission, resource = nil)
|
|
12
|
+
raise NotImplementedError, "Subclasses must implement #can?"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Check if a user has a specific role
|
|
16
|
+
# @param user [Object] The user object from your auth system
|
|
17
|
+
# @param role [Symbol, String] The role to check
|
|
18
|
+
# @return [Boolean] true if user has role, false otherwise
|
|
19
|
+
def has_role?(user, role)
|
|
20
|
+
raise NotImplementedError, "Subclasses must implement #has_role?"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check if a user is active/enabled
|
|
24
|
+
# @param user [Object] The user object from your auth system
|
|
25
|
+
# @return [Boolean] true if user is active, false otherwise
|
|
26
|
+
def active?(user)
|
|
27
|
+
return false unless user
|
|
28
|
+
|
|
29
|
+
# Default implementation - can be overridden
|
|
30
|
+
user.respond_to?(:active?) ? user.active? : true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get user ID for audit/logging purposes
|
|
34
|
+
# @param user [Object] The user object from your auth system
|
|
35
|
+
# @return [String, Integer] User identifier
|
|
36
|
+
def user_id(user)
|
|
37
|
+
return nil unless user
|
|
38
|
+
|
|
39
|
+
user.respond_to?(:id) ? user.id : user.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get user email for display/logging purposes
|
|
43
|
+
# @param user [Object] The user object from your auth system
|
|
44
|
+
# @return [String, nil] User email
|
|
45
|
+
def user_email(user)
|
|
46
|
+
return nil unless user
|
|
47
|
+
|
|
48
|
+
user.respond_to?(:email) ? user.email : nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Default adapter using the built-in User/Role/Permission system
|
|
53
|
+
class DefaultAdapter < RbacAdapter
|
|
54
|
+
def can?(user, permission, _resource = nil)
|
|
55
|
+
return false unless user
|
|
56
|
+
return false unless active?(user)
|
|
57
|
+
|
|
58
|
+
# Check if user has any role with the required permission
|
|
59
|
+
roles = extract_roles(user)
|
|
60
|
+
roles.any? do |role|
|
|
61
|
+
Role.has_permission?(role, permission)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def has_role?(user, role)
|
|
66
|
+
return false unless user
|
|
67
|
+
|
|
68
|
+
roles = extract_roles(user)
|
|
69
|
+
roles.include?(role.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def active?(user)
|
|
73
|
+
return false unless user
|
|
74
|
+
|
|
75
|
+
user.respond_to?(:active) ? user.active : true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def extract_roles(user)
|
|
81
|
+
if user.respond_to?(:roles)
|
|
82
|
+
Array(user.roles).map(&:to_sym)
|
|
83
|
+
elsif user.respond_to?(:role)
|
|
84
|
+
[user.role.to_sym]
|
|
85
|
+
else
|
|
86
|
+
[]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Adapter for Devise + CanCanCan integration
|
|
92
|
+
class DeviseCanCanAdapter < RbacAdapter
|
|
93
|
+
def initialize(ability_class: nil)
|
|
94
|
+
super()
|
|
95
|
+
@ability_class = ability_class
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def can?(user, permission, resource = nil)
|
|
99
|
+
return false unless user
|
|
100
|
+
return false unless active?(user)
|
|
101
|
+
|
|
102
|
+
# CanCanCan uses :can? method with action and resource
|
|
103
|
+
if user.respond_to?(:can?)
|
|
104
|
+
# Map permission to CanCanCan action
|
|
105
|
+
action = map_permission_to_action(permission)
|
|
106
|
+
user.can?(action, resource || Object)
|
|
107
|
+
elsif @ability_class
|
|
108
|
+
# Use Ability class if provided
|
|
109
|
+
ability = @ability_class.new(user)
|
|
110
|
+
action = map_permission_to_action(permission)
|
|
111
|
+
ability.can?(action, resource || Object)
|
|
112
|
+
else
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def has_role?(user, role)
|
|
118
|
+
return false unless user
|
|
119
|
+
return false unless active?(user)
|
|
120
|
+
|
|
121
|
+
# Check if user has role via CanCanCan roles or other methods
|
|
122
|
+
if user.respond_to?(:has_role?)
|
|
123
|
+
user.has_role?(role)
|
|
124
|
+
elsif user.respond_to?(:roles)
|
|
125
|
+
user.roles.any? { |r| r.to_s == role.to_s || r.name.to_s == role.to_s }
|
|
126
|
+
else
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def active?(user)
|
|
132
|
+
return false unless user
|
|
133
|
+
|
|
134
|
+
# Devise typically uses active_for_authentication? or active?
|
|
135
|
+
if user.respond_to?(:active_for_authentication?)
|
|
136
|
+
user.active_for_authentication?
|
|
137
|
+
elsif user.respond_to?(:active?)
|
|
138
|
+
user.active?
|
|
139
|
+
else
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def map_permission_to_action(permission)
|
|
147
|
+
# Map decision_agent permissions to CanCanCan actions
|
|
148
|
+
mapping = {
|
|
149
|
+
read: :read,
|
|
150
|
+
write: :create,
|
|
151
|
+
delete: :destroy,
|
|
152
|
+
approve: :approve,
|
|
153
|
+
deploy: :deploy,
|
|
154
|
+
manage_users: :manage,
|
|
155
|
+
audit: :read
|
|
156
|
+
}
|
|
157
|
+
mapping[permission.to_sym] || permission.to_sym
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Adapter for Pundit authorization
|
|
162
|
+
class PunditAdapter < RbacAdapter
|
|
163
|
+
def can?(user, permission, resource = nil)
|
|
164
|
+
return false unless user
|
|
165
|
+
return false unless active?(user)
|
|
166
|
+
|
|
167
|
+
# Pundit uses policy classes
|
|
168
|
+
if resource.respond_to?(:policy_class)
|
|
169
|
+
policy = resource.policy_class.new(user, resource)
|
|
170
|
+
action = map_permission_to_action(permission)
|
|
171
|
+
policy.respond_to?(action) && policy.public_send(action)
|
|
172
|
+
elsif resource
|
|
173
|
+
# Try to infer policy class from resource
|
|
174
|
+
policy_class_name = "#{resource.class.name}Policy"
|
|
175
|
+
if Object.const_defined?(policy_class_name)
|
|
176
|
+
policy_class = Object.const_get(policy_class_name)
|
|
177
|
+
policy = policy_class.new(user, resource)
|
|
178
|
+
action = map_permission_to_action(permission)
|
|
179
|
+
policy.respond_to?(action) && policy.public_send(action)
|
|
180
|
+
else
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def has_role?(user, role)
|
|
189
|
+
return false unless user
|
|
190
|
+
return false unless active?(user)
|
|
191
|
+
|
|
192
|
+
if user.respond_to?(:has_role?)
|
|
193
|
+
user.has_role?(role)
|
|
194
|
+
elsif user.respond_to?(:roles)
|
|
195
|
+
user.roles.any? { |r| r.to_s == role.to_s || r.name.to_s == role.to_s }
|
|
196
|
+
else
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def map_permission_to_action(permission)
|
|
204
|
+
mapping = {
|
|
205
|
+
read: :show,
|
|
206
|
+
write: :create,
|
|
207
|
+
delete: :destroy,
|
|
208
|
+
approve: :approve,
|
|
209
|
+
deploy: :deploy,
|
|
210
|
+
manage_users: :manage,
|
|
211
|
+
audit: :audit
|
|
212
|
+
}
|
|
213
|
+
mapping[permission.to_sym] || permission.to_sym
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Custom adapter that allows users to provide their own logic via blocks/procs
|
|
218
|
+
class CustomAdapter < RbacAdapter
|
|
219
|
+
def initialize(
|
|
220
|
+
can_proc: nil,
|
|
221
|
+
has_role_proc: nil,
|
|
222
|
+
active_proc: nil,
|
|
223
|
+
user_id_proc: nil,
|
|
224
|
+
user_email_proc: nil
|
|
225
|
+
)
|
|
226
|
+
super()
|
|
227
|
+
@can_proc = can_proc
|
|
228
|
+
@has_role_proc = has_role_proc
|
|
229
|
+
@active_proc = active_proc
|
|
230
|
+
@user_id_proc = user_id_proc
|
|
231
|
+
@user_email_proc = user_email_proc
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def can?(user, permission, resource = nil)
|
|
235
|
+
return false unless user
|
|
236
|
+
return false unless active?(user)
|
|
237
|
+
|
|
238
|
+
raise NotImplementedError, "CustomAdapter requires can_proc to be provided" unless @can_proc
|
|
239
|
+
|
|
240
|
+
@can_proc.call(user, permission, resource)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def has_role?(user, role)
|
|
244
|
+
return false unless user
|
|
245
|
+
|
|
246
|
+
raise NotImplementedError, "CustomAdapter requires has_role_proc to be provided" unless @has_role_proc
|
|
247
|
+
|
|
248
|
+
@has_role_proc.call(user, role)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def active?(user)
|
|
252
|
+
return false unless user
|
|
253
|
+
|
|
254
|
+
if @active_proc
|
|
255
|
+
@active_proc.call(user)
|
|
256
|
+
else
|
|
257
|
+
super
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def user_id(user)
|
|
262
|
+
if @user_id_proc
|
|
263
|
+
@user_id_proc.call(user)
|
|
264
|
+
else
|
|
265
|
+
super
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def user_email(user)
|
|
270
|
+
if @user_email_proc
|
|
271
|
+
@user_email_proc.call(user)
|
|
272
|
+
else
|
|
273
|
+
super
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
# Configuration class for RBAC adapter
|
|
4
|
+
class RbacConfig
|
|
5
|
+
attr_accessor :authenticator, :user_store
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@adapter = nil
|
|
9
|
+
@authenticator = nil
|
|
10
|
+
@user_store = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Configure with a built-in adapter
|
|
14
|
+
# @param adapter_type [Symbol] :default, :devise_cancan, :pundit, or :custom
|
|
15
|
+
# @param options [Hash] Options for the adapter
|
|
16
|
+
def use(adapter_type, **options)
|
|
17
|
+
case adapter_type.to_sym
|
|
18
|
+
when :default
|
|
19
|
+
@adapter = DefaultAdapter.new
|
|
20
|
+
when :devise_cancan
|
|
21
|
+
@adapter = DeviseCanCanAdapter.new(**options)
|
|
22
|
+
when :pundit
|
|
23
|
+
@adapter = PunditAdapter.new(**options)
|
|
24
|
+
when :custom
|
|
25
|
+
@adapter = CustomAdapter.new(**options)
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "Unknown adapter type: #{adapter_type}. Use :default, :devise_cancan, :pundit, or :custom"
|
|
28
|
+
end
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Configure with a custom adapter instance
|
|
33
|
+
# @param adapter_instance [RbacAdapter] An instance of RbacAdapter or subclass
|
|
34
|
+
def adapter=(adapter_instance)
|
|
35
|
+
raise ArgumentError, "Adapter must be an instance of DecisionAgent::Auth::RbacAdapter" unless adapter_instance.is_a?(RbacAdapter)
|
|
36
|
+
|
|
37
|
+
@adapter = adapter_instance
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get the configured adapter, or return default if none configured
|
|
41
|
+
def adapter
|
|
42
|
+
@adapter || DefaultAdapter.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if an adapter has been configured
|
|
46
|
+
def configured?
|
|
47
|
+
!@adapter.nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Auth
|
|
3
|
+
class Role
|
|
4
|
+
ROLES = {
|
|
5
|
+
admin: {
|
|
6
|
+
name: "Admin",
|
|
7
|
+
permissions: %i[read write delete approve deploy manage_users audit]
|
|
8
|
+
},
|
|
9
|
+
editor: {
|
|
10
|
+
name: "Editor",
|
|
11
|
+
permissions: %i[read write]
|
|
12
|
+
},
|
|
13
|
+
viewer: {
|
|
14
|
+
name: "Viewer",
|
|
15
|
+
permissions: [:read]
|
|
16
|
+
},
|
|
17
|
+
auditor: {
|
|
18
|
+
name: "Auditor",
|
|
19
|
+
permissions: %i[read audit]
|
|
20
|
+
},
|
|
21
|
+
approver: {
|
|
22
|
+
name: "Approver",
|
|
23
|
+
permissions: %i[read approve]
|
|
24
|
+
}
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def all
|
|
29
|
+
ROLES.keys
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def exists?(role)
|
|
33
|
+
ROLES.key?(role.to_sym)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def permissions_for(role)
|
|
37
|
+
role_data = ROLES[role.to_sym]
|
|
38
|
+
return [] unless role_data
|
|
39
|
+
|
|
40
|
+
role_data[:permissions]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def name_for(role)
|
|
44
|
+
role_data = ROLES[role.to_sym]
|
|
45
|
+
return nil unless role_data
|
|
46
|
+
|
|
47
|
+
role_data[:name]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def has_permission?(role, permission)
|
|
51
|
+
permissions_for(role).include?(permission.to_sym)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Auth
|
|
5
|
+
class Session
|
|
6
|
+
attr_reader :token, :user_id, :created_at, :expires_at
|
|
7
|
+
|
|
8
|
+
def initialize(user_id:, expires_in: 3600)
|
|
9
|
+
@token = SecureRandom.hex(32)
|
|
10
|
+
@user_id = user_id
|
|
11
|
+
@created_at = Time.now.utc
|
|
12
|
+
@expires_at = @created_at + expires_in
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def expired?
|
|
16
|
+
Time.now.utc > @expires_at
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def valid?
|
|
20
|
+
!expired?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
token: @token,
|
|
26
|
+
user_id: @user_id,
|
|
27
|
+
created_at: @created_at.iso8601,
|
|
28
|
+
expires_at: @expires_at.iso8601
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|