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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|