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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -6
@@ -1,19 +1,96 @@
1
1
  require "sinatra/base"
2
2
  require "json"
3
+ require "securerandom"
4
+ require "tempfile"
5
+
6
+ # Ensure testing classes are loaded
7
+ require_relative "../testing/test_scenario"
8
+ require_relative "../testing/batch_test_importer"
9
+ require_relative "../testing/batch_test_runner"
10
+ require_relative "../testing/test_result_comparator"
11
+ require_relative "../testing/test_coverage_analyzer"
12
+ require_relative "../evaluators/json_rule_evaluator"
13
+ require_relative "../agent"
14
+
15
+ # Auth components
16
+ require_relative "../auth/user"
17
+ require_relative "../auth/role"
18
+ require_relative "../auth/permission"
19
+ require_relative "../auth/session"
20
+ require_relative "../auth/session_manager"
21
+ require_relative "../auth/authenticator"
22
+ require_relative "../auth/permission_checker"
23
+ require_relative "../auth/access_audit_logger"
24
+ require_relative "middleware/auth_middleware"
25
+ require_relative "middleware/permission_middleware"
3
26
 
4
27
  module DecisionAgent
5
28
  module Web
29
+ # rubocop:disable Metrics/ClassLength
6
30
  class Server < Sinatra::Base
7
31
  set :public_folder, File.expand_path("public", __dir__)
8
32
  set :views, File.expand_path("views", __dir__)
9
33
  set :bind, "0.0.0.0"
10
34
  set :port, 4567
11
35
 
36
+ # In-memory storage for batch test runs
37
+ @batch_test_storage = {}
38
+ @batch_test_storage_mutex = Mutex.new
39
+
40
+ # Auth components
41
+ @authenticator = nil
42
+ @permission_checker = nil
43
+ @access_audit_logger = nil
44
+
45
+ def self.batch_test_storage
46
+ @batch_test_storage ||= {}
47
+ end
48
+
49
+ def self.batch_test_storage_mutex
50
+ @batch_test_storage_mutex ||= Mutex.new
51
+ end
52
+
53
+ class << self
54
+ attr_writer :authenticator
55
+ end
56
+
57
+ def self.authenticator
58
+ @authenticator ||= Auth::Authenticator.new
59
+ end
60
+
61
+ class << self
62
+ attr_writer :permission_checker
63
+ end
64
+
65
+ def self.permission_checker
66
+ @permission_checker ||= Auth::PermissionChecker.new(adapter: DecisionAgent.rbac_config.adapter)
67
+ end
68
+
69
+ class << self
70
+ attr_writer :access_audit_logger
71
+ end
72
+
73
+ def self.access_audit_logger
74
+ @access_audit_logger ||= Auth::AccessAuditLogger.new
75
+ end
76
+
12
77
  # Enable CORS for API calls
13
78
  before do
14
79
  headers["Access-Control-Allow-Origin"] = "*"
15
80
  headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"
16
- headers["Access-Control-Allow-Headers"] = "Content-Type"
81
+ headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
82
+ end
83
+
84
+ # Auth middleware - extract user from token
85
+ before do
86
+ token = extract_token
87
+ if token
88
+ auth_result = self.class.authenticator.authenticate(token)
89
+ if auth_result
90
+ @current_user = auth_result[:user]
91
+ @current_session = auth_result[:session]
92
+ end
93
+ end
17
94
  end
18
95
 
19
96
  # OPTIONS handler for CORS preflight
@@ -23,7 +100,46 @@ module DecisionAgent
23
100
 
24
101
  # Main page - serve the rule builder UI
25
102
  get "/" do
26
- send_file File.join(settings.public_folder, "index.html")
103
+ # Read the HTML file
104
+ html_file = File.join(settings.public_folder, "index.html")
105
+ unless File.exist?(html_file)
106
+ status 404
107
+ return "Index page not found"
108
+ end
109
+
110
+ html_content = File.read(html_file, encoding: "UTF-8")
111
+
112
+ # Determine the base path from the request
113
+ # When mounted in Rails, request.script_name contains the mount path
114
+ base_path = request.script_name.empty? ? "./" : "#{request.script_name}/"
115
+
116
+ # Inject or update base tag
117
+ base_tag = "<base href=\"#{base_path}\">"
118
+ html_content = if html_content.include?("<base")
119
+ # Replace existing base tag
120
+ html_content.sub(/<base[^>]*>/, base_tag)
121
+ else
122
+ # Insert base tag after <head>
123
+ html_content.sub("<head>", "<head>\n #{base_tag}")
124
+ end
125
+
126
+ content_type "text/html"
127
+ html_content
128
+ rescue StandardError => e
129
+ status 500
130
+ content_type "text/html"
131
+ "Error loading page: #{e.message}"
132
+ end
133
+
134
+ # Serve static assets explicitly (needed when mounted in Rails)
135
+ get "/styles.css" do
136
+ content_type "text/css"
137
+ send_file File.join(settings.public_folder, "styles.css")
138
+ end
139
+
140
+ get "/app.js" do
141
+ content_type "application/javascript"
142
+ send_file File.join(settings.public_folder, "app.js")
27
143
  end
28
144
 
29
145
  # API: Validate rules
@@ -219,11 +335,392 @@ module DecisionAgent
219
335
  { status: "ok", version: DecisionAgent::VERSION }.to_json
220
336
  end
221
337
 
338
+ # Authentication API endpoints
339
+
340
+ # POST /api/auth/login - User login
341
+ post "/api/auth/login" do
342
+ content_type :json
343
+
344
+ begin
345
+ request_body = request.body.read
346
+ data = JSON.parse(request_body)
347
+
348
+ email = data["email"]
349
+ password = data["password"]
350
+
351
+ unless email && password
352
+ status 400
353
+ return { error: "Email and password are required" }.to_json
354
+ end
355
+
356
+ session = self.class.authenticator.login(email, password)
357
+
358
+ unless session
359
+ self.class.access_audit_logger.log_authentication(
360
+ "login",
361
+ user_id: nil,
362
+ email: email,
363
+ success: false,
364
+ reason: "Invalid credentials"
365
+ )
366
+ status 401
367
+ return { error: "Invalid email or password" }.to_json
368
+ end
369
+
370
+ user = self.class.authenticator.find_user(session.user_id)
371
+
372
+ self.class.access_audit_logger.log_authentication(
373
+ "login",
374
+ user_id: user.id,
375
+ email: user.email,
376
+ success: true
377
+ )
378
+
379
+ {
380
+ token: session.token,
381
+ user: user.to_h,
382
+ expires_at: session.expires_at.iso8601
383
+ }.to_json
384
+ rescue JSON::ParserError
385
+ status 400
386
+ { error: "Invalid JSON" }.to_json
387
+ rescue StandardError => e
388
+ status 500
389
+ { error: e.message }.to_json
390
+ end
391
+ end
392
+
393
+ # POST /api/auth/logout - User logout
394
+ post "/api/auth/logout" do
395
+ content_type :json
396
+
397
+ begin
398
+ token = extract_token
399
+ if token
400
+ self.class.authenticator.logout(token)
401
+ if @current_user
402
+ checker = self.class.permission_checker
403
+ self.class.access_audit_logger.log_authentication(
404
+ "logout",
405
+ user_id: checker.user_id(@current_user),
406
+ email: checker.user_email(@current_user),
407
+ success: true
408
+ )
409
+ end
410
+ end
411
+
412
+ { success: true, message: "Logged out successfully" }.to_json
413
+ rescue StandardError => e
414
+ status 500
415
+ { error: e.message }.to_json
416
+ end
417
+ end
418
+
419
+ # GET /api/auth/me - Current user info
420
+ get "/api/auth/me" do
421
+ content_type :json
422
+
423
+ if @current_user
424
+ @current_user.to_h.to_json
425
+ else
426
+ status 401
427
+ { error: "Not authenticated" }.to_json
428
+ end
429
+ end
430
+
431
+ # GET /api/auth/roles - List all roles
432
+ get "/api/auth/roles" do
433
+ content_type :json
434
+ require_permission!(:read)
435
+
436
+ roles = Auth::Role.all.map do |role|
437
+ {
438
+ id: role.to_s,
439
+ name: Auth::Role.name_for(role),
440
+ permissions: Auth::Role.permissions_for(role).map(&:to_s)
441
+ }
442
+ end
443
+
444
+ roles.to_json
445
+ end
446
+
447
+ # POST /api/auth/users - Create user (admin only)
448
+ post "/api/auth/users" do
449
+ content_type :json
450
+ require_permission!(:manage_users)
451
+
452
+ begin
453
+ request_body = request.body.read
454
+ data = JSON.parse(request_body)
455
+
456
+ email = data["email"]
457
+ password = data["password"]
458
+ roles = data["roles"] || []
459
+
460
+ unless email && password
461
+ status 400
462
+ return { error: "Email and password are required" }.to_json
463
+ end
464
+
465
+ # Validate roles
466
+ roles.each do |role|
467
+ unless Auth::Role.exists?(role)
468
+ status 400
469
+ return { error: "Invalid role: #{role}" }.to_json
470
+ end
471
+ end
472
+
473
+ user = self.class.authenticator.create_user(
474
+ email: email,
475
+ password: password,
476
+ roles: roles
477
+ )
478
+
479
+ checker = self.class.permission_checker
480
+ self.class.access_audit_logger.log_access(
481
+ user_id: checker.user_id(@current_user),
482
+ action: "create_user",
483
+ resource_type: "user",
484
+ resource_id: user.id,
485
+ success: true
486
+ )
487
+
488
+ status 201
489
+ user.to_h.to_json
490
+ rescue JSON::ParserError
491
+ status 400
492
+ { error: "Invalid JSON" }.to_json
493
+ rescue StandardError => e
494
+ status 500
495
+ { error: e.message }.to_json
496
+ end
497
+ end
498
+
499
+ # GET /api/auth/users - List users (admin only)
500
+ get "/api/auth/users" do
501
+ content_type :json
502
+ require_permission!(:manage_users)
503
+
504
+ users = self.class.authenticator.user_store.all.map(&:to_h)
505
+ users.to_json
506
+ end
507
+
508
+ # POST /api/auth/users/:id/roles - Assign role to user (admin only)
509
+ post "/api/auth/users/:id/roles" do
510
+ content_type :json
511
+ require_permission!(:manage_users)
512
+
513
+ begin
514
+ user_id = params[:id]
515
+ request_body = request.body.read
516
+ data = JSON.parse(request_body)
517
+
518
+ role = data["role"]
519
+
520
+ unless role
521
+ status 400
522
+ return { error: "Role is required" }.to_json
523
+ end
524
+
525
+ unless Auth::Role.exists?(role)
526
+ status 400
527
+ return { error: "Invalid role: #{role}" }.to_json
528
+ end
529
+
530
+ user = self.class.authenticator.find_user(user_id)
531
+ unless user
532
+ status 404
533
+ return { error: "User not found" }.to_json
534
+ end
535
+
536
+ user.assign_role(role)
537
+
538
+ checker = self.class.permission_checker
539
+ self.class.access_audit_logger.log_access(
540
+ user_id: checker.user_id(@current_user),
541
+ action: "assign_role",
542
+ resource_type: "user",
543
+ resource_id: user.id,
544
+ success: true
545
+ )
546
+
547
+ user.to_h.to_json
548
+ rescue JSON::ParserError
549
+ status 400
550
+ { error: "Invalid JSON" }.to_json
551
+ rescue StandardError => e
552
+ status 500
553
+ { error: e.message }.to_json
554
+ end
555
+ end
556
+
557
+ # DELETE /api/auth/users/:id/roles/:role - Remove role from user (admin only)
558
+ delete "/api/auth/users/:id/roles/:role" do
559
+ content_type :json
560
+ require_permission!(:manage_users)
561
+
562
+ begin
563
+ user_id = params[:id]
564
+ role = params[:role]
565
+
566
+ user = self.class.authenticator.find_user(user_id)
567
+ unless user
568
+ status 404
569
+ return { error: "User not found" }.to_json
570
+ end
571
+
572
+ user.remove_role(role)
573
+
574
+ checker = self.class.permission_checker
575
+ self.class.access_audit_logger.log_access(
576
+ user_id: checker.user_id(@current_user),
577
+ action: "remove_role",
578
+ resource_type: "user",
579
+ resource_id: user.id,
580
+ success: true
581
+ )
582
+
583
+ user.to_h.to_json
584
+ rescue StandardError => e
585
+ status 500
586
+ { error: e.message }.to_json
587
+ end
588
+ end
589
+
590
+ # GET /api/auth/audit - Query access audit logs
591
+ get "/api/auth/audit" do
592
+ content_type :json
593
+ require_permission!(:audit)
594
+
595
+ begin
596
+ filters = {}
597
+
598
+ filters[:user_id] = params[:user_id] if params[:user_id]
599
+ filters[:event_type] = params[:event_type] if params[:event_type]
600
+ filters[:start_time] = params[:start_time] if params[:start_time]
601
+ filters[:end_time] = params[:end_time] if params[:end_time]
602
+ filters[:limit] = params[:limit]&.to_i if params[:limit]
603
+
604
+ logs = self.class.access_audit_logger.query(filters)
605
+ logs.to_json
606
+ rescue StandardError => e
607
+ status 500
608
+ { error: e.message }.to_json
609
+ end
610
+ end
611
+
612
+ # POST /api/auth/password/reset-request - Request password reset
613
+ post "/api/auth/password/reset-request" do
614
+ content_type :json
615
+
616
+ begin
617
+ request_body = request.body.read
618
+ data = JSON.parse(request_body)
619
+
620
+ email = data["email"]
621
+
622
+ unless email
623
+ status 400
624
+ return { error: "Email is required" }.to_json
625
+ end
626
+
627
+ token = self.class.authenticator.request_password_reset(email)
628
+
629
+ # For security, we always return success even if user doesn't exist
630
+ # In production, you would send the token via email
631
+ if token
632
+ self.class.access_audit_logger.log_authentication(
633
+ "password_reset_request",
634
+ user_id: token.user_id,
635
+ email: email,
636
+ success: true
637
+ )
638
+
639
+ {
640
+ success: true,
641
+ message: "If the email exists, a password reset token has been generated",
642
+ # In production, remove this token from response and send via email
643
+ token: token.token,
644
+ expires_at: token.expires_at.iso8601
645
+ }.to_json
646
+ else
647
+ # Log failed attempt (but don't reveal if user exists)
648
+ self.class.access_audit_logger.log_authentication(
649
+ "password_reset_request",
650
+ user_id: nil,
651
+ email: email,
652
+ success: false,
653
+ reason: "User not found or inactive"
654
+ )
655
+
656
+ {
657
+ success: true,
658
+ message: "If the email exists, a password reset token has been generated"
659
+ }.to_json
660
+ end
661
+ rescue JSON::ParserError
662
+ status 400
663
+ { error: "Invalid JSON" }.to_json
664
+ rescue StandardError => e
665
+ status 500
666
+ { error: e.message }.to_json
667
+ end
668
+ end
669
+
670
+ # POST /api/auth/password/reset - Reset password with token
671
+ post "/api/auth/password/reset" do
672
+ content_type :json
673
+
674
+ begin
675
+ request_body = request.body.read
676
+ data = JSON.parse(request_body)
677
+
678
+ token = data["token"]
679
+ new_password = data["password"]
680
+
681
+ unless token && new_password
682
+ status 400
683
+ return { error: "Token and password are required" }.to_json
684
+ end
685
+
686
+ unless new_password.length >= 8
687
+ status 400
688
+ return { error: "Password must be at least 8 characters long" }.to_json
689
+ end
690
+
691
+ user = self.class.authenticator.reset_password(token, new_password)
692
+
693
+ unless user
694
+ status 400
695
+ return { error: "Invalid or expired reset token" }.to_json
696
+ end
697
+
698
+ self.class.access_audit_logger.log_authentication(
699
+ "password_reset",
700
+ user_id: user.id,
701
+ email: user.email,
702
+ success: true
703
+ )
704
+
705
+ {
706
+ success: true,
707
+ message: "Password has been reset successfully"
708
+ }.to_json
709
+ rescue JSON::ParserError
710
+ status 400
711
+ { error: "Invalid JSON" }.to_json
712
+ rescue StandardError => e
713
+ status 500
714
+ { error: e.message }.to_json
715
+ end
716
+ end
717
+
222
718
  # Versioning API endpoints
223
719
 
224
720
  # Create a new version
225
721
  post "/api/versions" do
226
722
  content_type :json
723
+ require_permission!(:write)
227
724
 
228
725
  begin
229
726
  request_body = request.body.read
@@ -231,7 +728,7 @@ module DecisionAgent
231
728
 
232
729
  rule_id = data["rule_id"]
233
730
  rule_content = data["content"]
234
- created_by = data["created_by"] || "system"
731
+ created_by = data["created_by"] || (@current_user&.email || "system")
235
732
  changelog = data["changelog"]
236
733
 
237
734
  version = version_manager.save_version(
@@ -252,6 +749,7 @@ module DecisionAgent
252
749
  # List all versions for a rule
253
750
  get "/api/rules/:rule_id/versions" do
254
751
  content_type :json
752
+ require_permission!(:read)
255
753
 
256
754
  begin
257
755
  rule_id = params[:rule_id]
@@ -269,6 +767,7 @@ module DecisionAgent
269
767
  # Get version history with metadata
270
768
  get "/api/rules/:rule_id/history" do
271
769
  content_type :json
770
+ require_permission!(:read)
272
771
 
273
772
  begin
274
773
  rule_id = params[:rule_id]
@@ -284,6 +783,7 @@ module DecisionAgent
284
783
  # Get a specific version
285
784
  get "/api/versions/:version_id" do
286
785
  content_type :json
786
+ require_permission!(:read)
287
787
 
288
788
  begin
289
789
  version_id = params[:version_id]
@@ -304,12 +804,13 @@ module DecisionAgent
304
804
  # Activate a version (rollback)
305
805
  post "/api/versions/:version_id/activate" do
306
806
  content_type :json
807
+ require_permission!(:deploy)
307
808
 
308
809
  begin
309
810
  version_id = params[:version_id]
310
811
  request_body = request.body.read
311
812
  data = request_body.empty? ? {} : JSON.parse(request_body)
312
- performed_by = data["performed_by"] || "system"
813
+ performed_by = data["performed_by"] || (@current_user&.email || "system")
313
814
 
314
815
  version = version_manager.rollback(
315
816
  version_id: version_id,
@@ -326,6 +827,7 @@ module DecisionAgent
326
827
  # Compare two versions
327
828
  get "/api/versions/:version_id_1/compare/:version_id_2" do
328
829
  content_type :json
830
+ require_permission!(:read)
329
831
 
330
832
  begin
331
833
  version_id_1 = params[:version_id_1]
@@ -353,30 +855,393 @@ module DecisionAgent
353
855
  content_type :json
354
856
 
355
857
  begin
858
+ require_permission!(:delete)
356
859
  version_id = params[:version_id]
357
860
 
358
- version_manager.delete_version(version_id: version_id)
861
+ # Ensure version_id is present
862
+ unless version_id
863
+ status 400
864
+ return { error: "Version ID is required" }.to_json
865
+ end
866
+
867
+ result = version_manager.delete_version(version_id: version_id)
359
868
 
360
- status 200
361
- { success: true, message: "Version deleted successfully" }.to_json
869
+ if result == false
870
+ status 404
871
+ { error: "Version not found" }.to_json
872
+ else
873
+ status 200
874
+ { success: true, message: "Version deleted successfully" }.to_json
875
+ end
362
876
  rescue DecisionAgent::NotFoundError => e
363
877
  status 404
364
878
  { error: e.message }.to_json
365
879
  rescue DecisionAgent::ValidationError => e
366
880
  status 422
367
881
  { error: e.message }.to_json
882
+ rescue StandardError
883
+ # Log the error for debugging but return a safe response
884
+ # In production, you might want to log this to a proper logger
885
+ status 500
886
+ { error: "Internal server error" }.to_json
887
+ end
888
+ end
889
+
890
+ # Batch Testing API Endpoints
891
+
892
+ # POST /api/testing/batch/import - Upload CSV/Excel file
893
+ post "/api/testing/batch/import" do
894
+ content_type :json
895
+
896
+ begin
897
+ unless params[:file] && params[:file][:tempfile]
898
+ status 400
899
+ return { error: "No file uploaded" }.to_json
900
+ end
901
+
902
+ uploaded_file = params[:file][:tempfile]
903
+ filename = params[:file][:filename] || "uploaded_file"
904
+ file_extension = File.extname(filename).downcase
905
+
906
+ # Create temporary file
907
+ temp_file = Tempfile.new(["batch_test", file_extension])
908
+ temp_file.binmode
909
+ temp_file.write(uploaded_file.read)
910
+ temp_file.rewind
911
+
912
+ # Import scenarios based on file type
913
+ importer = DecisionAgent::Testing::BatchTestImporter.new
914
+
915
+ scenarios = if [".xlsx", ".xls"].include?(file_extension)
916
+ importer.import_excel(temp_file.path)
917
+ else
918
+ importer.import_csv(temp_file.path)
919
+ end
920
+
921
+ temp_file.close
922
+ temp_file.unlink
923
+
924
+ # Check for import errors - return error status if there are errors and no scenarios
925
+ if importer.errors.any? && scenarios.empty?
926
+ status 422
927
+ return { error: "Import failed: #{importer.errors.join('; ')}" }.to_json
928
+ end
929
+
930
+ # If there are errors but some scenarios were created, still return error status
931
+ # to indicate partial failure
932
+ if importer.errors.any?
933
+ status 422
934
+ return {
935
+ error: "Import completed with errors: #{importer.errors.join('; ')}",
936
+ test_id: nil,
937
+ scenarios_count: scenarios.size,
938
+ errors: importer.errors,
939
+ warnings: importer.warnings
940
+ }.to_json
941
+ end
942
+
943
+ # Store scenarios with a unique ID
944
+ test_id = SecureRandom.uuid
945
+ self.class.batch_test_storage_mutex.synchronize do
946
+ self.class.batch_test_storage[test_id] = {
947
+ id: test_id,
948
+ scenarios: scenarios,
949
+ status: "imported",
950
+ created_at: Time.now.utc.iso8601,
951
+ results: nil,
952
+ coverage: nil
953
+ }
954
+ end
955
+
956
+ status 201
957
+ {
958
+ test_id: test_id,
959
+ scenarios_count: scenarios.size,
960
+ errors: importer.errors,
961
+ warnings: importer.warnings
962
+ }.to_json
963
+ rescue DecisionAgent::ImportError => e
964
+ status 422
965
+ { error: e.message, errors: importer&.errors || [] }.to_json
966
+ rescue StandardError => e
967
+ status 500
968
+ { error: "Failed to import file: #{e.message}" }.to_json
969
+ end
970
+ end
971
+
972
+ # POST /api/testing/batch/run - Execute batch test
973
+ post "/api/testing/batch/run" do
974
+ content_type :json
975
+
976
+ begin
977
+ request_body = request.body.read
978
+ data = request_body.empty? ? {} : JSON.parse(request_body)
979
+
980
+ test_id = data["test_id"] || params[:test_id]
981
+ rules_json = data["rules"]
982
+ options = data["options"] || {}
983
+
984
+ unless test_id
985
+ status 400
986
+ return { error: "test_id is required" }.to_json
987
+ end
988
+
989
+ unless rules_json
990
+ status 400
991
+ return { error: "rules JSON is required" }.to_json
992
+ end
993
+
994
+ # Get stored scenarios
995
+ test_data = nil
996
+ self.class.batch_test_storage_mutex.synchronize do
997
+ test_data = self.class.batch_test_storage[test_id]
998
+ end
999
+
1000
+ unless test_data
1001
+ status 404
1002
+ return { error: "Test not found" }.to_json
1003
+ end
1004
+
1005
+ # Create agent from rules
1006
+ evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules_json)
1007
+ agent = DecisionAgent::Agent.new(evaluators: [evaluator])
1008
+
1009
+ # Update status
1010
+ self.class.batch_test_storage_mutex.synchronize do
1011
+ self.class.batch_test_storage[test_id][:status] = "running"
1012
+ self.class.batch_test_storage[test_id][:started_at] = Time.now.utc.iso8601
1013
+ end
1014
+
1015
+ # Run batch test
1016
+ runner = DecisionAgent::Testing::BatchTestRunner.new(agent)
1017
+ results = runner.run(
1018
+ test_data[:scenarios],
1019
+ parallel: options.fetch("parallel", true),
1020
+ thread_count: options.fetch("thread_count", 4),
1021
+ checkpoint_file: options["checkpoint_file"]
1022
+ )
1023
+
1024
+ # Calculate comparison if expected results exist
1025
+ comparison = nil
1026
+ if test_data[:scenarios].any?(&:expected_result?)
1027
+ comparator = DecisionAgent::Testing::TestResultComparator.new
1028
+ comparison = comparator.compare(results, test_data[:scenarios])
1029
+ end
1030
+
1031
+ # Calculate coverage
1032
+ coverage_analyzer = DecisionAgent::Testing::TestCoverageAnalyzer.new
1033
+ coverage = coverage_analyzer.analyze(results, agent)
1034
+
1035
+ # Store results
1036
+ self.class.batch_test_storage_mutex.synchronize do
1037
+ self.class.batch_test_storage[test_id][:status] = "completed"
1038
+ self.class.batch_test_storage[test_id][:results] = results.map(&:to_h)
1039
+ self.class.batch_test_storage[test_id][:comparison] = comparison
1040
+ self.class.batch_test_storage[test_id][:coverage] = coverage.to_h
1041
+ self.class.batch_test_storage[test_id][:statistics] = runner.statistics
1042
+ self.class.batch_test_storage[test_id][:completed_at] = Time.now.utc.iso8601
1043
+ end
1044
+
1045
+ {
1046
+ test_id: test_id,
1047
+ status: "completed",
1048
+ results_count: results.size,
1049
+ statistics: runner.statistics,
1050
+ comparison: comparison,
1051
+ coverage: coverage.to_h
1052
+ }.to_json
1053
+ rescue StandardError => e
1054
+ # Update status to failed
1055
+ if test_id
1056
+ self.class.batch_test_storage_mutex.synchronize do
1057
+ if self.class.batch_test_storage[test_id]
1058
+ self.class.batch_test_storage[test_id][:status] = "failed"
1059
+ self.class.batch_test_storage[test_id][:error] = e.message
1060
+ end
1061
+ end
1062
+ end
1063
+
1064
+ status 500
1065
+ { error: "Batch test execution failed: #{e.message}" }.to_json
1066
+ end
1067
+ end
1068
+
1069
+ # GET /api/testing/batch/:id/results - Get batch test results
1070
+ get "/api/testing/batch/:id/results" do
1071
+ content_type :json
1072
+
1073
+ begin
1074
+ test_id = params[:id]
1075
+
1076
+ test_data = nil
1077
+ self.class.batch_test_storage_mutex.synchronize do
1078
+ test_data = self.class.batch_test_storage[test_id]
1079
+ end
1080
+
1081
+ unless test_data
1082
+ status 404
1083
+ return { error: "Test not found" }.to_json
1084
+ end
1085
+
1086
+ {
1087
+ test_id: test_data[:id],
1088
+ status: test_data[:status],
1089
+ created_at: test_data[:created_at],
1090
+ started_at: test_data[:started_at],
1091
+ completed_at: test_data[:completed_at],
1092
+ scenarios_count: test_data[:scenarios]&.size || 0,
1093
+ results: test_data[:results],
1094
+ comparison: test_data[:comparison],
1095
+ statistics: test_data[:statistics],
1096
+ error: test_data[:error]
1097
+ }.to_json
1098
+ rescue StandardError => e
1099
+ status 500
1100
+ { error: e.message }.to_json
1101
+ end
1102
+ end
1103
+
1104
+ # GET /api/testing/batch/:id/coverage - Get coverage report
1105
+ get "/api/testing/batch/:id/coverage" do
1106
+ content_type :json
1107
+
1108
+ begin
1109
+ test_id = params[:id]
1110
+
1111
+ test_data = nil
1112
+ self.class.batch_test_storage_mutex.synchronize do
1113
+ test_data = self.class.batch_test_storage[test_id]
1114
+ end
1115
+
1116
+ unless test_data
1117
+ status 404
1118
+ return { error: "Test not found" }.to_json
1119
+ end
1120
+
1121
+ unless test_data[:coverage]
1122
+ status 404
1123
+ return { error: "Coverage report not available. Run the batch test first." }.to_json
1124
+ end
1125
+
1126
+ {
1127
+ test_id: test_data[:id],
1128
+ coverage: test_data[:coverage]
1129
+ }.to_json
368
1130
  rescue StandardError => e
369
1131
  status 500
370
1132
  { error: e.message }.to_json
371
1133
  end
372
1134
  end
373
1135
 
1136
+ # GET /testing/batch - Batch testing UI page
1137
+ get "/testing/batch" do
1138
+ send_file File.join(settings.public_folder, "batch_testing.html")
1139
+ rescue StandardError
1140
+ status 404
1141
+ "Batch testing page not found"
1142
+ end
1143
+
1144
+ # GET /auth/login - Login page
1145
+ get "/auth/login" do
1146
+ send_file File.join(settings.public_folder, "login.html")
1147
+ rescue StandardError
1148
+ status 404
1149
+ "Login page not found"
1150
+ end
1151
+
1152
+ # GET /auth/users - User management page
1153
+ get "/auth/users" do
1154
+ send_file File.join(settings.public_folder, "users.html")
1155
+ rescue StandardError
1156
+ status 404
1157
+ "User management page not found"
1158
+ end
1159
+
374
1160
  private
375
1161
 
376
1162
  def version_manager
377
1163
  @version_manager ||= DecisionAgent::Versioning::VersionManager.new
378
1164
  end
379
1165
 
1166
+ def extract_token
1167
+ # Check Authorization header: Bearer <token>
1168
+ auth_header = request.env["HTTP_AUTHORIZATION"]
1169
+ return auth_header[7..] if auth_header&.start_with?("Bearer ")
1170
+
1171
+ # Check session cookie
1172
+ cookie_token = request.cookies["decision_agent_session"]
1173
+ return cookie_token if cookie_token
1174
+
1175
+ # Check query parameter
1176
+ params["token"]
1177
+ end
1178
+
1179
+ attr_reader :current_user
1180
+
1181
+ def require_authentication!
1182
+ return if @current_user
1183
+
1184
+ content_type :json
1185
+ halt 401, { error: "Authentication required" }.to_json
1186
+ end
1187
+
1188
+ def require_permission!(permission, resource = nil)
1189
+ # Always require authentication first
1190
+ require_authentication!
1191
+
1192
+ # Skip permission checks if disabled via environment variable
1193
+ # Useful for development environments
1194
+ # This allows authenticated users to bypass permission checks
1195
+ return true if permissions_disabled?
1196
+
1197
+ checker = self.class.permission_checker
1198
+ unless checker.can?(@current_user, permission, resource)
1199
+ begin
1200
+ self.class.access_audit_logger.log_permission_check(
1201
+ user_id: checker.user_id(@current_user),
1202
+ permission: permission,
1203
+ resource_type: resource&.class&.name,
1204
+ resource_id: resource&.id,
1205
+ granted: false
1206
+ )
1207
+ rescue StandardError
1208
+ # If logging fails, continue with permission denial
1209
+ end
1210
+ # Move halt outside ensure block - Ruby 3.1 compatibility
1211
+ # Placing halt here instead of ensure block fixes Ruby 3.1 issue where
1212
+ # halt inside ensure doesn't reliably stop execution
1213
+ content_type :json
1214
+ halt 403, { error: "Permission denied: #{permission}" }.to_json
1215
+ end
1216
+
1217
+ begin
1218
+ self.class.access_audit_logger.log_permission_check(
1219
+ user_id: checker.user_id(@current_user),
1220
+ permission: permission,
1221
+ resource_type: resource&.class&.name,
1222
+ resource_id: resource&.id,
1223
+ granted: true
1224
+ )
1225
+ rescue StandardError
1226
+ # If logging fails, continue - permission was granted
1227
+ end
1228
+ end
1229
+
1230
+ def permissions_disabled?
1231
+ # Check explicit environment variable first
1232
+ # Make it case-insensitive and handle whitespace
1233
+ disable_flag = ENV.fetch("DISABLE_WEBUI_PERMISSIONS", nil)
1234
+ if disable_flag
1235
+ normalized = disable_flag.to_s.strip.downcase
1236
+ return true if %w[true 1 yes].include?(normalized)
1237
+ return false if %w[false 0 no].include?(normalized)
1238
+ end
1239
+
1240
+ # Auto-disable in development environments if not explicitly set
1241
+ env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
1242
+ env == "development"
1243
+ end
1244
+
380
1245
  def parse_validation_errors(error_message)
381
1246
  # Extract individual errors from the formatted error message
382
1247
  errors = []
@@ -411,5 +1276,6 @@ module DecisionAgent
411
1276
  new.call(env)
412
1277
  end
413
1278
  end
1279
+ # rubocop:enable Metrics/ClassLength
414
1280
  end
415
1281
  end