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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ VERSION = "0.1.0"
5
+ end