decision_agent 0.1.3 → 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_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- 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 +164 -7
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- 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 +59 -0
- data/lib/generators/decision_agent/install/install_generator.rb +37 -0
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -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 +612 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -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 -548
- data/spec/issue_verification_spec.rb +95 -21
- data/spec/monitoring/metrics_collector_spec.rb +221 -3
- data/spec/monitoring/monitored_agent_spec.rb +1 -1
- data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
- data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -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 +123 -6
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module ABTesting
|
|
3
|
+
module Storage
|
|
4
|
+
# Base adapter interface for A/B test persistence
|
|
5
|
+
class Adapter
|
|
6
|
+
# Save an A/B test
|
|
7
|
+
# @param test [ABTest] The test to save
|
|
8
|
+
# @return [ABTest] The saved test with ID
|
|
9
|
+
def save_test(test)
|
|
10
|
+
raise NotImplementedError, "#{self.class} must implement #save_test"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Get an A/B test by ID
|
|
14
|
+
# @param test_id [String, Integer] The test ID
|
|
15
|
+
# @return [ABTest, nil] The test or nil
|
|
16
|
+
def get_test(test_id)
|
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #get_test"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Update an A/B test
|
|
21
|
+
# @param test_id [String, Integer] The test ID
|
|
22
|
+
# @param attributes [Hash] Attributes to update
|
|
23
|
+
# @return [ABTest] The updated test
|
|
24
|
+
def update_test(test_id, attributes)
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #update_test"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# List A/B tests
|
|
29
|
+
# @param status [String, nil] Filter by status
|
|
30
|
+
# @param limit [Integer, nil] Limit results
|
|
31
|
+
# @return [Array<ABTest>] Array of tests
|
|
32
|
+
def list_tests(status: nil, limit: nil)
|
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #list_tests"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Save an assignment
|
|
37
|
+
# @param assignment [ABTestAssignment] The assignment to save
|
|
38
|
+
# @return [ABTestAssignment] The saved assignment with ID
|
|
39
|
+
def save_assignment(assignment)
|
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #save_assignment"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Update an assignment
|
|
44
|
+
# @param assignment_id [String, Integer] The assignment ID
|
|
45
|
+
# @param attributes [Hash] Attributes to update
|
|
46
|
+
# @return [ABTestAssignment] The updated assignment
|
|
47
|
+
def update_assignment(assignment_id, attributes)
|
|
48
|
+
raise NotImplementedError, "#{self.class} must implement #update_assignment"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get assignments for a test
|
|
52
|
+
# @param test_id [String, Integer] The test ID
|
|
53
|
+
# @return [Array<ABTestAssignment>] Array of assignments
|
|
54
|
+
def get_assignments(test_id)
|
|
55
|
+
raise NotImplementedError, "#{self.class} must implement #get_assignments"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delete a test and its assignments
|
|
59
|
+
# @param test_id [String, Integer] The test ID
|
|
60
|
+
# @return [Boolean] True if deleted
|
|
61
|
+
def delete_test(test_id)
|
|
62
|
+
raise NotImplementedError, "#{self.class} must implement #delete_test"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
require "monitor"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module ABTesting
|
|
5
|
+
module Storage
|
|
6
|
+
# In-memory storage adapter for A/B tests
|
|
7
|
+
# Useful for testing and development
|
|
8
|
+
class MemoryAdapter < Adapter
|
|
9
|
+
include MonitorMixin
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
@tests = {}
|
|
14
|
+
@assignments = {}
|
|
15
|
+
@test_id_counter = 0
|
|
16
|
+
@assignment_id_counter = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save_test(test)
|
|
20
|
+
synchronize do
|
|
21
|
+
@test_id_counter += 1
|
|
22
|
+
test_data = test.to_h.merge(id: @test_id_counter)
|
|
23
|
+
@tests[@test_id_counter] = test_data
|
|
24
|
+
|
|
25
|
+
ABTest.new(**test_data)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get_test(test_id)
|
|
30
|
+
synchronize do
|
|
31
|
+
test_data = @tests[test_id.to_i]
|
|
32
|
+
test_data ? ABTest.new(**test_data) : nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update_test(test_id, attributes)
|
|
37
|
+
synchronize do
|
|
38
|
+
test_data = @tests[test_id.to_i]
|
|
39
|
+
raise TestNotFoundError, "Test not found: #{test_id}" unless test_data
|
|
40
|
+
|
|
41
|
+
test_data.merge!(attributes)
|
|
42
|
+
@tests[test_id.to_i] = test_data
|
|
43
|
+
|
|
44
|
+
ABTest.new(**test_data)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def list_tests(status: nil, limit: nil)
|
|
49
|
+
synchronize do
|
|
50
|
+
tests = @tests.values
|
|
51
|
+
|
|
52
|
+
tests = tests.select { |t| t[:status] == status } if status
|
|
53
|
+
tests = tests.last(limit) if limit
|
|
54
|
+
|
|
55
|
+
tests.map { |t| ABTest.new(**t) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def save_assignment(assignment)
|
|
60
|
+
synchronize do
|
|
61
|
+
@assignment_id_counter += 1
|
|
62
|
+
assignment_data = assignment.to_h.merge(id: @assignment_id_counter)
|
|
63
|
+
@assignments[@assignment_id_counter] = assignment_data
|
|
64
|
+
|
|
65
|
+
ABTestAssignment.new(**assignment_data)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update_assignment(assignment_id, attributes)
|
|
70
|
+
synchronize do
|
|
71
|
+
assignment_data = @assignments[assignment_id.to_i]
|
|
72
|
+
raise "Assignment not found: #{assignment_id}" unless assignment_data
|
|
73
|
+
|
|
74
|
+
assignment_data.merge!(attributes)
|
|
75
|
+
@assignments[assignment_id.to_i] = assignment_data
|
|
76
|
+
|
|
77
|
+
ABTestAssignment.new(**assignment_data)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def get_assignments(test_id)
|
|
82
|
+
synchronize do
|
|
83
|
+
assignments = @assignments.values.select { |a| a[:ab_test_id] == test_id }
|
|
84
|
+
assignments.map { |a| ABTestAssignment.new(**a) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete_test(test_id)
|
|
89
|
+
synchronize do
|
|
90
|
+
@tests.delete(test_id.to_i)
|
|
91
|
+
@assignments.delete_if { |_id, a| a[:ab_test_id] == test_id }
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Additional helper methods
|
|
97
|
+
def clear!
|
|
98
|
+
synchronize do
|
|
99
|
+
@tests.clear
|
|
100
|
+
@assignments.clear
|
|
101
|
+
@test_id_counter = 0
|
|
102
|
+
@assignment_id_counter = 0
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_count
|
|
107
|
+
synchronize { @tests.size }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def assignment_count
|
|
111
|
+
synchronize { @assignments.size }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/decision_agent/agent.rb
CHANGED
|
@@ -6,10 +6,12 @@ module DecisionAgent
|
|
|
6
6
|
class Agent
|
|
7
7
|
attr_reader :evaluators, :scoring_strategy, :audit_adapter
|
|
8
8
|
|
|
9
|
-
def initialize(evaluators:, scoring_strategy: nil, audit_adapter: nil)
|
|
9
|
+
def initialize(evaluators:, scoring_strategy: nil, audit_adapter: nil, validate_evaluations: nil)
|
|
10
10
|
@evaluators = Array(evaluators)
|
|
11
11
|
@scoring_strategy = scoring_strategy || Scoring::WeightedAverage.new
|
|
12
12
|
@audit_adapter = audit_adapter || Audit::NullAdapter.new
|
|
13
|
+
# Default to validating in development, skip in production for performance
|
|
14
|
+
@validate_evaluations = validate_evaluations.nil? ? (ENV["RAILS_ENV"] != "production") : validate_evaluations
|
|
13
15
|
|
|
14
16
|
validate_configuration!
|
|
15
17
|
|
|
@@ -24,8 +26,8 @@ module DecisionAgent
|
|
|
24
26
|
|
|
25
27
|
raise NoEvaluationsError if evaluations.empty?
|
|
26
28
|
|
|
27
|
-
# Validate all evaluations for correctness and thread-safety
|
|
28
|
-
EvaluationValidator.validate_all!(evaluations)
|
|
29
|
+
# Validate all evaluations for correctness and thread-safety (optional for performance)
|
|
30
|
+
EvaluationValidator.validate_all!(evaluations) if @validate_evaluations
|
|
29
31
|
|
|
30
32
|
scored_result = @scoring_strategy.score(evaluations)
|
|
31
33
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../audit/adapter"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Auth
|
|
6
|
+
class AccessAuditLogger
|
|
7
|
+
attr_reader :adapter
|
|
8
|
+
|
|
9
|
+
def initialize(adapter: nil)
|
|
10
|
+
@adapter = adapter || Audit::InMemoryAccessAdapter.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def log_authentication(event_type, user_id:, email: nil, success: true, reason: nil)
|
|
14
|
+
log_entry = {
|
|
15
|
+
event_type: event_type.to_s,
|
|
16
|
+
user_id: user_id,
|
|
17
|
+
email: email,
|
|
18
|
+
success: success,
|
|
19
|
+
reason: reason,
|
|
20
|
+
timestamp: Time.now.utc.iso8601,
|
|
21
|
+
ip_address: nil # Can be set by middleware
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@adapter.record_access(log_entry)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def log_permission_check(user_id:, permission:, resource_type: nil, resource_id: nil, granted: true)
|
|
28
|
+
log_entry = {
|
|
29
|
+
event_type: "permission_check",
|
|
30
|
+
user_id: user_id,
|
|
31
|
+
permission: permission.to_s,
|
|
32
|
+
resource_type: resource_type,
|
|
33
|
+
resource_id: resource_id,
|
|
34
|
+
granted: granted,
|
|
35
|
+
timestamp: Time.now.utc.iso8601
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@adapter.record_access(log_entry)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_access(user_id:, action:, resource_type: nil, resource_id: nil, success: true)
|
|
42
|
+
log_entry = {
|
|
43
|
+
event_type: "access",
|
|
44
|
+
user_id: user_id,
|
|
45
|
+
action: action.to_s,
|
|
46
|
+
resource_type: resource_type,
|
|
47
|
+
resource_id: resource_id,
|
|
48
|
+
success: success,
|
|
49
|
+
timestamp: Time.now.utc.iso8601
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@adapter.record_access(log_entry)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def query(filters = {})
|
|
56
|
+
@adapter.query_access_logs(filters)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module Audit
|
|
62
|
+
class AccessAdapter < Adapter
|
|
63
|
+
def record_access(log_entry)
|
|
64
|
+
raise NotImplementedError, "Subclasses must implement #record_access"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def query_access_logs(filters = {})
|
|
68
|
+
raise NotImplementedError, "Subclasses must implement #query_access_logs"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class InMemoryAccessAdapter < AccessAdapter
|
|
73
|
+
def initialize
|
|
74
|
+
super
|
|
75
|
+
@logs = []
|
|
76
|
+
@mutex = Mutex.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def record_access(log_entry)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@logs << log_entry.dup
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def query_access_logs(filters = {})
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
results = @logs.dup
|
|
88
|
+
|
|
89
|
+
results.select! { |log| log[:user_id] == filters[:user_id] } if filters[:user_id]
|
|
90
|
+
|
|
91
|
+
results.select! { |log| log[:event_type] == filters[:event_type].to_s } if filters[:event_type]
|
|
92
|
+
|
|
93
|
+
if filters[:start_time]
|
|
94
|
+
start_time = filters[:start_time].is_a?(String) ? Time.parse(filters[:start_time]) : filters[:start_time]
|
|
95
|
+
results.select! { |log| Time.parse(log[:timestamp]) >= start_time }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if filters[:end_time]
|
|
99
|
+
end_time = filters[:end_time].is_a?(String) ? Time.parse(filters[:end_time]) : filters[:end_time]
|
|
100
|
+
results.select! { |log| Time.parse(log[:timestamp]) <= end_time }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
results = results.last(filters[:limit]) if filters[:limit]
|
|
104
|
+
|
|
105
|
+
results.reverse # Most recent first
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def all_logs
|
|
110
|
+
@mutex.synchronize do
|
|
111
|
+
@logs.dup
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def clear
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
@logs.clear
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -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
|