rails_ai_admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Reads schema metadata only. Suggestions never register or expose a field.
|
|
5
|
+
class SchemaSuggestions
|
|
6
|
+
Suggestion = Data.define(:name, :type, :options) do
|
|
7
|
+
def declaration
|
|
8
|
+
suffix = options.map { |key, value| ", #{key}: #{value.inspect}" }.join
|
|
9
|
+
"#{type} :#{name}#{suffix}"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
Report = Data.define(:suggestions, :omitted, :available)
|
|
13
|
+
TYPES = { string: :text, text: :textarea, integer: :integer, decimal: :decimal,
|
|
14
|
+
boolean: :boolean, date: :date, datetime: :datetime, timestamp: :datetime, time: :time,
|
|
15
|
+
json: :json, jsonb: :json }.freeze
|
|
16
|
+
EMAIL_NAME = /(?:\A|_)email(?:_address)?\z/
|
|
17
|
+
URL_NAME = /(?:\A|_)(?:url|website|homepage)\z/
|
|
18
|
+
# Heuristics are a conservative starting point, never a sensitivity proof.
|
|
19
|
+
SENSITIVE = /password|token|secret|credential|ciphertext|encrypted|(?:\A|_)(?:api_key|private_key|access_key|signing_key|otp|ssn|salt|session|digest|role|roles|permission|permissions|security_version|admin|superuser)(?:_|\z)|_digest\z/i
|
|
20
|
+
IDENTIFIER = /\A[a-z][a-z0-9_]*\z/
|
|
21
|
+
# Reassigning a tenant is an authority change, so tenant-like references are
|
|
22
|
+
# never suggested, even as comments.
|
|
23
|
+
TENANT = /\A(?:account|tenant|organization|organisation|workspace|company|team)\z/
|
|
24
|
+
|
|
25
|
+
def self.sensitive?(model, name)
|
|
26
|
+
encrypted = model.respond_to?(:encrypted_attributes) ? model.encrypted_attributes : []
|
|
27
|
+
name.to_s.match?(SENSITIVE) || Array(encrypted&.to_a).map(&:to_s).include?(name.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.array?(column)
|
|
31
|
+
column.respond_to?(:array) && column.array
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(model)
|
|
35
|
+
@model = model
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
return unavailable unless @model.is_a?(Class) && @model < ActiveRecord::Base
|
|
40
|
+
columns = @model.columns
|
|
41
|
+
foreign_keys = @model.reflect_on_all_associations(:belongs_to).flat_map { |association| Array(association.foreign_key) }.map(&:to_s)
|
|
42
|
+
foreign_keys.concat(@model.reflect_on_all_associations(:belongs_to).filter_map { |association| association.foreign_type if association.polymorphic? })
|
|
43
|
+
foreign_keys.concat(@model.connection.foreign_keys(@model.table_name).flat_map { |foreign_key| Array(foreign_key.column) }.map(&:to_s))
|
|
44
|
+
# A declared non-polymorphic belongs_to may become a commented reference.
|
|
45
|
+
references = @model.reflect_on_all_associations(:belongs_to).reject(&:polymorphic?).to_h { |association| [ association.foreign_key.to_s, association ] }
|
|
46
|
+
omitted = 0
|
|
47
|
+
suggestions = columns.filter_map do |column|
|
|
48
|
+
name = column.name
|
|
49
|
+
next if name == @model.primary_key || name == "id"
|
|
50
|
+
if (reference = suggest_reference(references[name], column))
|
|
51
|
+
next reference
|
|
52
|
+
end
|
|
53
|
+
if !name.match?(IDENTIFIER) || [ "lock_version", @model.inheritance_column ].include?(name) || name.end_with?("_id") || foreign_keys.include?(name) || self.class.sensitive?(@model, name) || self.class.array?(column)
|
|
54
|
+
omitted += 1
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
type = TYPES[column.type]
|
|
58
|
+
unless type
|
|
59
|
+
omitted += 1
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
options = {}
|
|
63
|
+
enum = @model.defined_enums[name]
|
|
64
|
+
if enum && (!enum.keys.all? { |value| value.length <= 100 } || !enum.size.between?(1, 100))
|
|
65
|
+
omitted += 1
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
type = :email if type == :text && name.match?(EMAIL_NAME)
|
|
69
|
+
type = :url if type == :text && name.match?(URL_NAME)
|
|
70
|
+
if enum
|
|
71
|
+
type = :enum
|
|
72
|
+
options[:values] = enum.keys
|
|
73
|
+
end
|
|
74
|
+
options[:nullable] = false unless column.null
|
|
75
|
+
options[:max_length] = column.limit if %i[text textarea email url].include?(type) && column.limit.is_a?(Integer) && column.limit.positive?
|
|
76
|
+
# Explicit limits keep the declaration reviewable; coercion would
|
|
77
|
+
# otherwise read the same values from the column on first use.
|
|
78
|
+
if type == :decimal && column.precision.is_a?(Integer) && column.precision.between?(1, 1000)
|
|
79
|
+
options[:precision] = column.precision
|
|
80
|
+
options[:scale] = column.scale if column.scale.is_a?(Integer) && column.scale.between?(0, column.precision)
|
|
81
|
+
end
|
|
82
|
+
options[:readonly] = true if %w[created_at updated_at].include?(name)
|
|
83
|
+
Suggestion.new(name, type, options.freeze)
|
|
84
|
+
end
|
|
85
|
+
Report.new(suggestions.freeze, omitted, true)
|
|
86
|
+
rescue ActiveRecord::ActiveRecordError
|
|
87
|
+
unavailable
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def suggest_reference(association, column)
|
|
93
|
+
return unless association && association.name.to_s.match?(IDENTIFIER) && column.name.match?(IDENTIFIER)
|
|
94
|
+
return if association.name.to_s.match?(TENANT) || self.class.sensitive?(@model, association.name) || self.class.sensitive?(@model, column.name)
|
|
95
|
+
options = {}
|
|
96
|
+
options[:foreign_key] = column.name unless column.name == "#{association.name}_id"
|
|
97
|
+
options[:nullable] = false unless column.null
|
|
98
|
+
Suggestion.new(association.name.to_s, :belongs_to, options.freeze)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unavailable
|
|
102
|
+
Report.new([].freeze, 0, false)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
module Selections
|
|
7
|
+
# Captures exact scoped targets; never saves a query to expand during execution.
|
|
8
|
+
class Builder
|
|
9
|
+
LIMIT = 100
|
|
10
|
+
Target = Data.define(:id, :expected_version, :label)
|
|
11
|
+
Snapshot = Data.define(:selection_ref, :resource_key, :manifest_digest, :expires_at, :targets)
|
|
12
|
+
|
|
13
|
+
def capture(context:, resource:, target_ids:)
|
|
14
|
+
access = Authorization::Access.new(context, resource)
|
|
15
|
+
raise AccessDenied unless RailsAiAdmin.registry.fetch(resource.key).equal?(resource)
|
|
16
|
+
ensure_storage!(resource)
|
|
17
|
+
targets = ActiveRecord::Base.while_preventing_writes do
|
|
18
|
+
access.authorize_selection!
|
|
19
|
+
ids = canonical_ids(resource, target_ids)
|
|
20
|
+
records = scoped_records(access, ids)
|
|
21
|
+
records.map { |record| target(access, record) }.sort_by(&:id).freeze
|
|
22
|
+
end
|
|
23
|
+
version = resource_version(resource)
|
|
24
|
+
selection = Selection.transaction(requires_new: true) do
|
|
25
|
+
row = Selection.create!(id: SecureRandom.uuid, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
|
|
26
|
+
security_version: context.security_version, resource_key: resource.key.to_s, resource_version: version,
|
|
27
|
+
manifest_digest: manifest_digest(version, targets), item_count: targets.size,
|
|
28
|
+
expires_at: Time.current + RailsAiAdmin.configuration.intent_ttl)
|
|
29
|
+
SelectionItem.insert_all!(targets.each_with_index.map { |item, position| { selection_id: row.id,
|
|
30
|
+
target_id: item.id, expected_version: item.expected_version, position: position } })
|
|
31
|
+
row
|
|
32
|
+
end
|
|
33
|
+
raise ConfigurationError, "Selection creation was rolled back" unless selection
|
|
34
|
+
snapshot(selection, targets)
|
|
35
|
+
rescue ActiveRecord::ReadOnlyError
|
|
36
|
+
raise ConfigurationError, "Selection lookup must be read-only", cause: nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# This is a fresh check, not a lock or authorization for a later write.
|
|
40
|
+
# Execution must bind this digest and recheck each item in its transaction.
|
|
41
|
+
# A block receives each current record, inside the same read-only check.
|
|
42
|
+
def revalidate(context:, selection_ref:, resource: nil, manifest_digest: nil, &block)
|
|
43
|
+
read_snapshot(context: context, selection_ref: selection_ref, resource: resource, manifest_digest: manifest_digest, labels: true, &block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Internal admission path: no title projection or domain preflight under
|
|
47
|
+
# the parent lock. Only exact target checks and the given authorization run.
|
|
48
|
+
def validate_for_admission(context:, selection_ref:, resource:, manifest_digest:, &authorize_target)
|
|
49
|
+
read_snapshot(context: context, selection_ref: selection_ref, resource: resource, manifest_digest: manifest_digest, labels: false, &authorize_target)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def read_snapshot(context:, selection_ref:, resource:, manifest_digest:, labels:)
|
|
55
|
+
raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
|
|
56
|
+
unless selection_ref.is_a?(String) && selection_ref.match?(/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/)
|
|
57
|
+
raise AccessDenied
|
|
58
|
+
end
|
|
59
|
+
selection = Selection.find_by(id: selection_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
|
|
60
|
+
raise AccessDenied unless selection
|
|
61
|
+
raise AccessDenied, "SELECTION_INVALIDATED" unless selection.security_version == context.security_version
|
|
62
|
+
raise AccessDenied, "SELECTION_EXPIRED" unless selection.expires_at > Time.current
|
|
63
|
+
registered = RailsAiAdmin.registry.fetch(selection.resource_key)
|
|
64
|
+
raise AccessDenied unless resource.nil? || registered.equal?(resource)
|
|
65
|
+
ensure_storage!(registered)
|
|
66
|
+
raise AccessDenied, "SELECTION_INVALIDATED" unless selection.resource_version == resource_version(registered)
|
|
67
|
+
items = SelectionItem.where(selection_id: selection.id).order(:position).limit(LIMIT + 1).to_a
|
|
68
|
+
verify_manifest!(selection, items)
|
|
69
|
+
raise AccessDenied, "SELECTION_INVALIDATED" if manifest_digest && manifest_digest != selection.manifest_digest
|
|
70
|
+
access = Authorization::Access.new(context, registered)
|
|
71
|
+
targets = ActiveRecord::Base.while_preventing_writes do
|
|
72
|
+
access.authorize_selection!
|
|
73
|
+
records = scoped_records(access, items.map(&:target_id)).index_by { |record| record.id.to_s }
|
|
74
|
+
items.map do |item|
|
|
75
|
+
record = records.fetch(item.target_id)
|
|
76
|
+
access.authorize!(:show, record)
|
|
77
|
+
raise AccessDenied, "CONFLICT" unless RecordVersion.of(record) == item.expected_version
|
|
78
|
+
value = target(access, record, labels: labels, authorized: true)
|
|
79
|
+
yield record if block_given?
|
|
80
|
+
value
|
|
81
|
+
end.freeze
|
|
82
|
+
end
|
|
83
|
+
snapshot(selection, targets)
|
|
84
|
+
rescue ActiveRecord::ReadOnlyError
|
|
85
|
+
raise ConfigurationError, "Selection lookup must be read-only", cause: nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def ensure_storage!(resource)
|
|
91
|
+
model = resource.model_class
|
|
92
|
+
unless [ Selection, SelectionItem ].all? { |klass| klass.connection_pool.equal?(model.connection_pool) } &&
|
|
93
|
+
RecordVersion.supported?(model) &&
|
|
94
|
+
%w[SQLite PostgreSQL].include?(model.connection.adapter_name)
|
|
95
|
+
raise ConfigurationError, "Selections require one SQLite/PostgreSQL pool, a single primary key and lock_version or updated_at"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def canonical_ids(resource, values)
|
|
100
|
+
unless values.is_a?(Array) && values.size.between?(1, LIMIT)
|
|
101
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
102
|
+
end
|
|
103
|
+
type = resource.model_class.type_for_attribute(resource.model_class.primary_key)
|
|
104
|
+
unless %i[integer string uuid].include?(type.type)
|
|
105
|
+
raise ConfigurationError, "Selection primary keys must be integer, string or UUID"
|
|
106
|
+
end
|
|
107
|
+
ids = values.map do |value|
|
|
108
|
+
unless (value.is_a?(String) || value.is_a?(Integer)) && value.to_s.bytesize.between?(1, 150) && !value.to_s.match?(/[[:cntrl:]]/) &&
|
|
109
|
+
(type.type != :integer || value.to_s.match?(/\A[+-]?\d+\z/))
|
|
110
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
111
|
+
end
|
|
112
|
+
cast = type.cast(value)
|
|
113
|
+
raise AccessDenied, "INVALID_QUERY" if cast.nil? || cast.to_s.bytesize > 150
|
|
114
|
+
cast.to_s
|
|
115
|
+
end
|
|
116
|
+
# A repeated box (or "1" and "01") is one target, not an invalid query (RAA-013).
|
|
117
|
+
ids.uniq
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def scoped_records(access, ids)
|
|
121
|
+
model = access.resource.model_class
|
|
122
|
+
primary_key = model.arel_table[model.primary_key]
|
|
123
|
+
authorized_ids = access.relation.where(primary_key.in(ids)).reorder(nil).select(primary_key).distinct
|
|
124
|
+
# Reconstitute only IDs selected by the complete authorized relation.
|
|
125
|
+
# DISTINCT on model.* fails for PostgreSQL JSON columns; deduplicate IDs
|
|
126
|
+
# inside the scoped subquery before applying the outer bound instead.
|
|
127
|
+
records = model.unscoped.where(model.primary_key => authorized_ids).order(primary_key.asc).limit(LIMIT + 1).to_a
|
|
128
|
+
raise AccessDenied unless records.map { |record| record.id.to_s }.sort == ids.sort
|
|
129
|
+
records
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def target(access, record, labels: true, authorized: false)
|
|
133
|
+
access.authorize!(:show, record) unless authorized
|
|
134
|
+
version = RecordVersion.of(record)
|
|
135
|
+
raise ConfigurationError, "Selection versions must be nonnegative integers" unless version.is_a?(Integer) && version >= 0
|
|
136
|
+
label = labels ? access.record_label(record) : "Record ##{record.id}"
|
|
137
|
+
Target.new(record.id.to_s.freeze, version, label.freeze)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resource_version(resource)
|
|
141
|
+
InputSchema.digest(key: resource.key, identity: ResourceIdentity.call(resource), policy: resource.policy, readonly: resource.readonly?,
|
|
142
|
+
adapter: RailsAiAdmin.configuration.authorization_adapter, fields: resource.fields.map(&:contract))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def manifest_digest(version, targets)
|
|
146
|
+
InputSchema.digest(resource_version: version, targets: targets.map { |item| [ item.id, item.expected_version ] })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def verify_manifest!(selection, items)
|
|
150
|
+
unless selection.item_count.between?(1, LIMIT) && items.size == selection.item_count && items.map(&:position) == (0...items.size).to_a &&
|
|
151
|
+
items.map(&:target_id).uniq.size == items.size && selection.manifest_digest == InputSchema.digest(resource_version: selection.resource_version,
|
|
152
|
+
targets: items.map { |item| [ item.target_id, item.expected_version ] })
|
|
153
|
+
raise ConfigurationError, "Selection manifest is inconsistent"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def snapshot(selection, targets)
|
|
158
|
+
Snapshot.new(selection.id.freeze, selection.resource_key.freeze, selection.manifest_digest.freeze, selection.expires_at, targets)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
module Templates
|
|
7
|
+
# Explicit public presentation paths. Revision changes track context/locals
|
|
8
|
+
# compatibility even when the template bytes themselves do not change.
|
|
9
|
+
class Catalog
|
|
10
|
+
Entry = Data.define(:key, :path, :revision, :assigns, :locals)
|
|
11
|
+
DEFINITIONS = {
|
|
12
|
+
"layout" => [ "layouts/rails_ai_admin/application.html.erb", %i[context admin_csp_nonce], [] ],
|
|
13
|
+
"status_layout" => [ "layouts/rails_ai_admin/status.html.erb", %i[admin_status], [] ],
|
|
14
|
+
"status_page" => [ "rails_ai_admin/statuses/show.html.erb", %i[admin_status status_sign_in_path], [] ],
|
|
15
|
+
"session_links" => [ "rails_ai_admin/shared/_session.html.erb", %i[context], [] ],
|
|
16
|
+
"resource_navigation" => [ "rails_ai_admin/shared/_navigation.html.erb", [], %i[groups] ],
|
|
17
|
+
"dashboard" => [ "rails_ai_admin/dashboard/index.html.erb", %i[context counts widgets], [] ],
|
|
18
|
+
"audit_log" => [ "rails_ai_admin/audit/index.html.erb", %i[context log filters page actor_labels], [] ],
|
|
19
|
+
"resource_import" => [ "rails_ai_admin/imports/new.html.erb", %i[context resource import report problem], [] ],
|
|
20
|
+
"search" => [ "rails_ai_admin/search/index.html.erb", %i[context query sections], [] ],
|
|
21
|
+
"resource_index" => [ "rails_ai_admin/resources/index.html.erb", %i[context resource page columns rows searchable selectable exportable importable sortable_fields list_scopes current_scope filter_fields omitted_rows], [] ],
|
|
22
|
+
"resource_show" => [ "rails_ai_admin/resources/show.html.erb", %i[context resource record values fields operations association_panels], [] ],
|
|
23
|
+
"resource_history" => [ "rails_ai_admin/resources/history.html.erb", %i[context resource record history actor_labels], [] ],
|
|
24
|
+
"resource_associations" => [ "rails_ai_admin/resources/_associations.html.erb", [], %i[panels] ],
|
|
25
|
+
"resource_new" => [ "rails_ai_admin/resources/new.html.erb", %i[context resource record operation intent_ref form_fields form_values form_plan errors failure_code needs_reload], [] ],
|
|
26
|
+
"resource_edit" => [ "rails_ai_admin/resources/edit.html.erb", %i[context resource record operation intent_ref form_fields form_values form_plan errors failure_code needs_reload], [] ],
|
|
27
|
+
"resource_form" => [ "rails_ai_admin/resources/form.html.erb", %i[context resource record operation intent_ref form_fields form_values form_plan errors failure_code needs_reload], [] ],
|
|
28
|
+
"resource_destroy" => [ "rails_ai_admin/resources/confirm_destroy.html.erb", %i[context resource record intent_ref errors failure_code needs_reload], [] ],
|
|
29
|
+
"form_recovery" => [ "rails_ai_admin/resources/_recovery.html.erb", [], [] ],
|
|
30
|
+
"resource_errors" => [ "rails_ai_admin/resources/_errors.html.erb", %i[errors failure_code needs_reload], %i[form fields] ],
|
|
31
|
+
"field_index" => [ "rails_ai_admin/fields/_index.html.erb", [], %i[field value text] ],
|
|
32
|
+
"field_show" => [ "rails_ai_admin/fields/_show.html.erb", [], %i[field value text] ],
|
|
33
|
+
"field_reference" => [ "rails_ai_admin/fields/_reference.html.erb", [], %i[field value reference] ],
|
|
34
|
+
"field_form" => [ "rails_ai_admin/fields/_input.html.erb", [], %i[field form value input_options] ],
|
|
35
|
+
"field_row" => [ "rails_ai_admin/fields/_row.html.erb", [], %i[form entry errors] ],
|
|
36
|
+
"input_fields" => [ "rails_ai_admin/shared/_input_fields.html.erb", %i[resource], %i[form fields values errors plan] ],
|
|
37
|
+
"operation_form" => [ "rails_ai_admin/operations/form.html.erb", %i[context resource record definition flow_id form_fields form_values errors failure_code needs_reload], [] ],
|
|
38
|
+
"operation_confirmation" => [ "rails_ai_admin/operations/confirmation.html.erb", %i[context resource record definition flow_id proposal form_fields form_values wire_input], [] ],
|
|
39
|
+
"operation_failure" => [ "rails_ai_admin/operations/failure.html.erb", %i[context resource failure_code], [] ],
|
|
40
|
+
"collection_form" => [ "rails_ai_admin/collection_operations/new.html.erb", %i[context resource definition selection form_fields form_values errors failure_code], [] ],
|
|
41
|
+
"collection_confirmation" => [ "rails_ai_admin/collection_operations/confirmation.html.erb", %i[context resource definition proposal wire_input], [] ],
|
|
42
|
+
"collection_failure" => [ "rails_ai_admin/collection_operations/failure.html.erb", %i[context resource failure_code uncertain_job_ref], [] ],
|
|
43
|
+
"job_index" => [ "rails_ai_admin/collection_jobs/index.html.erb", %i[context jobs], [] ],
|
|
44
|
+
"job_show" => [ "rails_ai_admin/collection_jobs/show.html.erb", %i[context resource job], [] ],
|
|
45
|
+
"selection" => [ "rails_ai_admin/selections/show.html.erb", %i[context resource selection operations deletable], [] ],
|
|
46
|
+
"selection_destroy" => [ "rails_ai_admin/selections/confirm_destroy.html.erb", %i[context resource selection deletable skipped], [] ],
|
|
47
|
+
"selection_destroy_result" => [ "rails_ai_admin/selections/destroyed.html.erb", %i[context resource selection deleted skipped failed], [] ]
|
|
48
|
+
}.freeze
|
|
49
|
+
ENTRIES = DEFINITIONS.map do |key, (path, assigns, locals)|
|
|
50
|
+
navigation = %w[layout resource_index resource_show resource_history resource_new resource_edit resource_form resource_destroy operation_form operation_confirmation operation_failure collection_form collection_confirmation collection_failure job_show selection selection_destroy selection_destroy_result].include?(key)
|
|
51
|
+
assigns += [ :list_navigation ] if navigation
|
|
52
|
+
revision = if key == "layout"
|
|
53
|
+
4 # page titles, environment label and the session_links slot (RAA-024)
|
|
54
|
+
elsif key == "dashboard"
|
|
55
|
+
4 # counts, widgets
|
|
56
|
+
elsif key == "resource_index"
|
|
57
|
+
8 # exportable, importable (CSV)
|
|
58
|
+
elsif key == "resource_show"
|
|
59
|
+
3
|
|
60
|
+
elsif %w[resource_new resource_edit resource_form].include?(key)
|
|
61
|
+
5 # intent_ref replaces intent (ADR 0025)
|
|
62
|
+
elsif %w[resource_destroy resource_errors].include?(key)
|
|
63
|
+
3 # intent_ref; outcome sentences and RESTRICTED
|
|
64
|
+
elsif navigation || %w[input_fields field_form].include?(key)
|
|
65
|
+
2
|
|
66
|
+
else
|
|
67
|
+
1
|
|
68
|
+
end
|
|
69
|
+
Entry.new(key.freeze, path.freeze, revision, assigns.freeze, locals.freeze)
|
|
70
|
+
end.freeze
|
|
71
|
+
|
|
72
|
+
def initialize(source_root: Engine.root.join("app/views"), entries: ENTRIES)
|
|
73
|
+
@source_root = Pathname.new(source_root).expand_path
|
|
74
|
+
@entries = entries.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def entries = @entries
|
|
78
|
+
def fetch(key) = entries.find { |entry| entry.key == key.to_s } || raise(ConfigurationError, "Unknown override key; use --list")
|
|
79
|
+
def for_path(path) = entries.find { |entry| entry.path == path }
|
|
80
|
+
def source_path(entry) = @source_root.join(entry.path)
|
|
81
|
+
def source_digest(entry) = Digest::SHA256.file(source_path(entry)).hexdigest
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_ai_admin/templates/catalog"
|
|
4
|
+
require "rails_ai_admin/templates/metadata"
|
|
5
|
+
require "rails_ai_admin/templates/safe_paths"
|
|
6
|
+
|
|
7
|
+
module RailsAiAdmin
|
|
8
|
+
module Templates
|
|
9
|
+
class Inspector
|
|
10
|
+
Finding = Data.define(:key, :status, :code, :path, :source_version)
|
|
11
|
+
MAX_FILES = 256
|
|
12
|
+
MAX_ENTRIES = 1024
|
|
13
|
+
|
|
14
|
+
def initialize(root:, catalog: Catalog.new, max_files: MAX_FILES, max_entries: MAX_ENTRIES)
|
|
15
|
+
unless max_files.is_a?(Integer) && max_files.between?(1, MAX_FILES) && max_entries.is_a?(Integer) && max_entries.between?(1, MAX_ENTRIES)
|
|
16
|
+
raise ConfigurationError, "Override inspection bounds may only narrow the defaults"
|
|
17
|
+
end
|
|
18
|
+
@root, @catalog = Pathname.new(root).realpath, catalog
|
|
19
|
+
@max_files, @max_entries = max_files, max_entries
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def checks
|
|
23
|
+
@findings, @visited, @files = [], 0, 0
|
|
24
|
+
catch(:limit) do
|
|
25
|
+
%w[rails_ai_admin layouts/rails_ai_admin].each do |namespace|
|
|
26
|
+
path = @root.join("app/views", namespace)
|
|
27
|
+
begin
|
|
28
|
+
SafePaths.check!(@root, path)
|
|
29
|
+
next unless path.exist?
|
|
30
|
+
raise ConfigurationError unless path.directory?
|
|
31
|
+
walk(path)
|
|
32
|
+
rescue ConfigurationError, SystemCallError
|
|
33
|
+
@findings << Finding.new(namespace, :error, "UNSAFE_PATH", "app/views/#{namespace}", nil)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
@findings.sort_by(&:key).freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def walk(directory)
|
|
43
|
+
Dir.each_child(directory) do |name|
|
|
44
|
+
path = directory.join(name)
|
|
45
|
+
@visited += 1
|
|
46
|
+
incomplete! if @visited > @max_entries
|
|
47
|
+
stat = path.lstat
|
|
48
|
+
if stat.directory?
|
|
49
|
+
if @catalog.for_path(path.relative_path_from(@root.join("app/views")).to_s)
|
|
50
|
+
relative = path.relative_path_from(@root).to_s
|
|
51
|
+
@findings << Finding.new(relative, :error, "UNSAFE_PATH", relative, nil)
|
|
52
|
+
else
|
|
53
|
+
walk(path)
|
|
54
|
+
end
|
|
55
|
+
elsif stat.symlink? || !stat.file?
|
|
56
|
+
relative = path.relative_path_from(@root).to_s
|
|
57
|
+
@findings << Finding.new(relative, :error, "UNSAFE_PATH", relative, nil)
|
|
58
|
+
elsif path.basename.to_s.end_with?(".html.erb")
|
|
59
|
+
@files += 1
|
|
60
|
+
incomplete! if @files > @max_files
|
|
61
|
+
@findings << inspect_file(path)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def incomplete!
|
|
67
|
+
@findings << Finding.new("inventory", :review, "INCOMPLETE", "app/views", nil)
|
|
68
|
+
throw :limit
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inspect_file(path)
|
|
72
|
+
relative = path.relative_path_from(@root.join("app/views")).to_s
|
|
73
|
+
entry = @catalog.for_path(relative)
|
|
74
|
+
key = entry&.key || relative
|
|
75
|
+
return Finding.new(key, :review, "UNCATALOGED", "app/views/#{relative}", nil) unless entry
|
|
76
|
+
SafePaths.check!(@root, path)
|
|
77
|
+
metadata = Metadata.read(path, entry)
|
|
78
|
+
return Finding.new(key, :review, "UNTRACKED", "app/views/#{relative}", nil) unless metadata
|
|
79
|
+
code, status = if metadata["contract"] != entry.revision
|
|
80
|
+
[ "STALE_CONTRACT", :review ]
|
|
81
|
+
elsif metadata["source_sha256"] != @catalog.source_digest(entry)
|
|
82
|
+
[ "STALE_SOURCE", :review ]
|
|
83
|
+
else
|
|
84
|
+
[ "CURRENT", :ok ]
|
|
85
|
+
end
|
|
86
|
+
Finding.new(key, status, code, "app/views/#{relative}", metadata["gem_version"])
|
|
87
|
+
rescue ConfigurationError, JSON::ParserError
|
|
88
|
+
Finding.new(key, :error, "INVALID_HEADER", "app/views/#{relative}", nil)
|
|
89
|
+
rescue SystemCallError
|
|
90
|
+
Finding.new(key, :error, "UNSAFE_PATH", "app/views/#{relative}", nil)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rails_ai_admin/version"
|
|
5
|
+
|
|
6
|
+
module RailsAiAdmin
|
|
7
|
+
module Templates
|
|
8
|
+
module Metadata
|
|
9
|
+
PREFIX = "<%# rails_ai_admin:override "
|
|
10
|
+
MAX_BYTES = 1024
|
|
11
|
+
KEYS = %w[format key gem_version source_sha256 contract].freeze
|
|
12
|
+
class HeaderObject < Hash
|
|
13
|
+
def []=(key, value)
|
|
14
|
+
raise JSON::ParserError, "Duplicate metadata key" if key?(key)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.line(entry, catalog)
|
|
20
|
+
data = { "format" => 1, "key" => entry.key, "gem_version" => VERSION,
|
|
21
|
+
"source_sha256" => catalog.source_digest(entry), "contract" => entry.revision }
|
|
22
|
+
"#{PREFIX}#{JSON.generate(data)} %>\n"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.read(path, entry)
|
|
26
|
+
flags = File::RDONLY
|
|
27
|
+
flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
|
|
28
|
+
line = File.open(path, flags) { |file| file.gets("\n", MAX_BYTES + 1) }.to_s.force_encoding(Encoding::UTF_8)
|
|
29
|
+
return unless line.b.start_with?(PREFIX.b)
|
|
30
|
+
raise ConfigurationError, "Invalid override header" if line.bytesize > MAX_BYTES || !line.valid_encoding?
|
|
31
|
+
json = line.match(/\A<%# rails_ai_admin:override (\{.*\}) %>\r?\n?\z/)&.captures&.first
|
|
32
|
+
raise ConfigurationError, "Invalid override header" unless json
|
|
33
|
+
# json 2.x builds objects without HeaderObject#[]=, so the parser's own
|
|
34
|
+
# duplicate check is what rejects a repeated key there.
|
|
35
|
+
data = JSON.parse(json, object_class: HeaderObject, max_nesting: 2, allow_duplicate_key: false)
|
|
36
|
+
unless data.keys.sort == KEYS.sort && data["format"].is_a?(Integer) && data["format"] == 1 && data["key"] == entry.key &&
|
|
37
|
+
data["contract"].is_a?(Integer) && data["contract"].between?(1, 1_000_000) &&
|
|
38
|
+
data["source_sha256"].is_a?(String) && data["source_sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
39
|
+
data["gem_version"].is_a?(String) && data["gem_version"].match?(/\A[0-9a-zA-Z][0-9a-zA-Z.-]{0,63}\z/) && Gem::Version.correct?(data["gem_version"])
|
|
40
|
+
raise ConfigurationError, "Invalid override header"
|
|
41
|
+
end
|
|
42
|
+
data.freeze
|
|
43
|
+
rescue JSON::ParserError, EncodingError
|
|
44
|
+
raise ConfigurationError, "Invalid override header", cause: nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Templates
|
|
5
|
+
module SafePaths
|
|
6
|
+
# Do not follow existing parent/leaf symlinks, including dangling links.
|
|
7
|
+
# The host root itself is explicitly selected and resolved by its owner.
|
|
8
|
+
def self.check!(root, path)
|
|
9
|
+
requested_root = Pathname.new(root).expand_path
|
|
10
|
+
relative = Pathname.new(path).expand_path.relative_path_from(requested_root)
|
|
11
|
+
root = requested_root.realpath
|
|
12
|
+
raise ConfigurationError, "Override path is outside host root" if relative.each_filename.any? { |part| part == ".." }
|
|
13
|
+
parts = relative.each_filename.to_a
|
|
14
|
+
current = root
|
|
15
|
+
parts.each_with_index do |part, index|
|
|
16
|
+
current = current.join(part)
|
|
17
|
+
begin
|
|
18
|
+
stat = current.lstat
|
|
19
|
+
rescue Errno::ENOENT
|
|
20
|
+
next
|
|
21
|
+
end
|
|
22
|
+
if stat.symlink? || index < parts.size - 1 && !stat.directory?
|
|
23
|
+
raise ConfigurationError, "Override path contains a symlink or invalid parent"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
root.join(relative)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_ai_admin"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
# Framework-neutral host assertions. Raises AssertionError without Minitest/RSpec.
|
|
7
|
+
module TestKit
|
|
8
|
+
class AssertionError < StandardError; end
|
|
9
|
+
FIELD_ACTIONS = %i[read write search sort filter].freeze
|
|
10
|
+
|
|
11
|
+
# Supply a host controller/request representing an anonymous session. This
|
|
12
|
+
# checks the adapter contract; also test the real anonymous HTTP entry point.
|
|
13
|
+
def self.assert_unauthenticated(adapter:, controller:)
|
|
14
|
+
raise AssertionError, "Anonymous context must resolve to nil" unless adapter.resolve(controller).nil?
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Snapshots independent field grants, intersected with DSL flags. No record
|
|
19
|
+
# values are returned. Action permission and SQL membership are separate.
|
|
20
|
+
def self.field_grants(context:, resource:, record: nil, operation: :update)
|
|
21
|
+
raise ArgumentError, "Use create or update field grants" unless %w[create update].include?(operation.to_s)
|
|
22
|
+
access = Authorization::Access.new(context, resource)
|
|
23
|
+
FIELD_ACTIONS.to_h do |action|
|
|
24
|
+
[ action.to_s, access.fields(action, record, operation: operation).map { |field| field.name.to_s }.sort ]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.assert_field_grants(context:, resource:, expected:, record: nil, operation: :update)
|
|
29
|
+
unless expected.is_a?(Hash) && expected.keys.map(&:to_s).sort == FIELD_ACTIONS.map(&:to_s).sort && expected.values.all? { |names| names.is_a?(Array) }
|
|
30
|
+
raise ArgumentError, "Expected read/write/search/sort/filter arrays"
|
|
31
|
+
end
|
|
32
|
+
normalized = expected.to_h { |action, names| [ action.to_s, names.map(&:to_s).sort ] }
|
|
33
|
+
actual = field_grants(context: context, resource: resource, record: record, operation: operation)
|
|
34
|
+
differences = FIELD_ACTIONS.map(&:to_s).select { |action| actual.fetch(action) != normalized.fetch(action) }
|
|
35
|
+
raise AssertionError, "Field grants differ for #{resource.key}: #{differences.join(', ')}" if differences.any?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Declared named scopes currently granted to this context, sorted by name.
|
|
40
|
+
def self.named_scope_grants(context:, resource:)
|
|
41
|
+
Authorization::Access.new(context, resource).named_scopes.map { |definition| definition.name.to_s }.sort
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.assert_named_scopes(context:, resource:, expected:)
|
|
45
|
+
raise ArgumentError, "Expected an array of scope names" unless expected.is_a?(Array)
|
|
46
|
+
actual = named_scope_grants(context: context, resource: resource)
|
|
47
|
+
unless actual == expected.map(&:to_s).sort
|
|
48
|
+
raise AssertionError, "Named scope grants differ for #{resource.key}: expected #{expected.map(&:to_s).sort.inspect}, got #{actual.inspect}"
|
|
49
|
+
end
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.assert_access(context:, resource:, action:, allowed:, record: nil)
|
|
54
|
+
access = Authorization::Access.new(context, resource)
|
|
55
|
+
actual = begin
|
|
56
|
+
access.authorize!(action, record)
|
|
57
|
+
true
|
|
58
|
+
rescue AccessDenied
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
raise AssertionError, "Expected #{resource.key}.#{action} allowed=#{allowed}, got #{actual}" unless actual == allowed
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.assert_isolated(context:, resource:, foreign_id:)
|
|
66
|
+
access = Authorization::Access.new(context, resource)
|
|
67
|
+
raise AssertionError, "Policy scope includes another tenant's record" if access.relation.where(resource.model_class.primary_key => foreign_id).exists?
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The policy scope and the record-level show decision should agree: list
|
|
72
|
+
# pages omit in-scope records whose show is denied (RAA-001), which reads as
|
|
73
|
+
# missing data. Checks up to sample records in primary-key order and names
|
|
74
|
+
# only how many disagree, never their content.
|
|
75
|
+
def self.assert_scope_matches_show(context:, resource:, sample: 100)
|
|
76
|
+
raise ArgumentError, "sample must be between 1 and 1000" unless sample.is_a?(Integer) && sample.between?(1, 1000)
|
|
77
|
+
access = Authorization::Access.new(context, resource)
|
|
78
|
+
model = resource.model_class
|
|
79
|
+
records = access.relation.reorder(model.arel_table[model.primary_key].asc).limit(sample).to_a
|
|
80
|
+
denied = records.count do |record|
|
|
81
|
+
access.authorize!(:show, record)
|
|
82
|
+
false
|
|
83
|
+
rescue AccessDenied
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
if denied.positive?
|
|
87
|
+
raise AssertionError, "#{denied} of #{records.size} sampled #{resource.key} records are in the policy scope but deny show; " \
|
|
88
|
+
"narrow the scope or allow show so list pages do not omit them"
|
|
89
|
+
end
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.assert_hidden(context:, resource:, field:, actions: FIELD_ACTIONS, record: nil)
|
|
94
|
+
access = Authorization::Access.new(context, resource)
|
|
95
|
+
actions.each do |action|
|
|
96
|
+
if access.fields(action, record).any? { |entry| entry.name == field.to_sym }
|
|
97
|
+
raise AssertionError, "#{field} is exposed through #{action}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|