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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Queries
5
+ # Bounded, permission-aware scalar index query. No SQL expressions enter it.
6
+ class List
7
+ # scope is the applied named scope's name, or nil for the policy relation.
8
+ # sort/direction describe the applied order (nil sort: primary key only).
9
+ # count is nil unless the resource opts in; count_capped marks COUNT_LIMIT.
10
+ Page = Data.define(:records, :number, :has_next, :scope, :sort, :direction, :page_size, :count, :count_capped)
11
+ # Default page size; resources may choose 1..100 through `index`.
12
+ PAGE_SIZE = 25
13
+ COUNT_LIMIT = Presentation::IndexSettings::COUNT_LIMIT
14
+ MAX_FILTER_CONDITIONS = 20
15
+ FILTER_VALUE = ->(value) { value.is_a?(String) && value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?) && value.length.between?(1, 200) && !value.match?(/[\x00-\x1f\x7f]/) }
16
+ AREL_PREDICATES = { "eq" => :eq, "gte" => :gteq, "lte" => :lteq }.freeze
17
+
18
+ def initialize(context, resource)
19
+ @access = Authorization::Access.new(context, resource)
20
+ @resource = resource
21
+ end
22
+
23
+ # scope: nil uses the declared default when it is currently granted;
24
+ # NamedScope::NONE applies no named scope at all.
25
+ # A named scope must be declared and granted; it is never discarded here.
26
+ # filters: { "field" => { "eq" | "gte" | "lte" => "wire string" } }, ANDed.
27
+ # Every field needs a current :filter grant and a declared operator.
28
+ def call(search: nil, sort: nil, direction: "asc", page: "1", recover_empty_page: false, scope: nil, filters: nil, page_size: nil, offset: nil)
29
+ @access.authorize!(:index)
30
+ unless (page.is_a?(String) || page.is_a?(Integer)) && page.to_s.valid_encoding? && page.to_s.ascii_only? && page.to_s.match?(/\A[1-9]\d{0,3}\z/) && page.to_i <= 1000
31
+ raise AccessDenied, "INVALID_QUERY"
32
+ end
33
+ raise AccessDenied, "INVALID_QUERY" unless %w[asc desc].include?(direction)
34
+ relation, named = filtered_relation(search: search, scope: scope, filters: filters)
35
+ settings = @resource.index
36
+ size = page_size || settings.page_size
37
+ raise AccessDenied, "INVALID_QUERY" unless size.is_a?(Integer) && size.between?(1, Presentation::IndexSettings::MAX_PAGE_SIZE)
38
+ raise AccessDenied, "INVALID_QUERY" unless offset.nil? || offset.is_a?(Integer) && offset.between?(0, 99_999) && !recover_empty_page
39
+ relation, field, direction = sorted(relation, sort, direction)
40
+ records = relation.offset(offset || (page.to_i - 1) * size).limit(size + 1).to_a
41
+ number = page.to_i
42
+ if recover_empty_page && records.empty? && number > 1
43
+ count = relation.except(:order).limit(number * size).count
44
+ number = [ 1, (count - 1).div(size) + 1 ].max
45
+ records = relation.offset((number - 1) * size).limit(size + 1).to_a
46
+ end
47
+ total = relation.except(:order).limit(COUNT_LIMIT + 1).count if settings.count == :exact
48
+ Page.new(records: records.first(size), number: number, has_next: records.size > size, scope: named&.name&.to_s,
49
+ sort: field&.name&.to_s, direction: field ? direction : "asc", page_size: size,
50
+ count: total && [ total, COUNT_LIMIT ].min, count_capped: !!total && total > COUNT_LIMIT)
51
+ end
52
+
53
+ # The complete ordered relation the index shows across all its pages,
54
+ # with the same grants and validation. Callers bound it themselves.
55
+ def relation(search: nil, sort: nil, direction: "asc", scope: nil, filters: nil)
56
+ @access.authorize!(:index)
57
+ raise AccessDenied, "INVALID_QUERY" unless %w[asc desc].include?(direction)
58
+ relation, = filtered_relation(search: search, scope: scope, filters: filters)
59
+ sorted(relation, sort, direction).first
60
+ end
61
+
62
+ # The bounded number of records the same search/scope/filters match,
63
+ # capped like the index count. Same grants and validation as call.
64
+ CountResult = Data.define(:count, :capped, :scope)
65
+ def count(search: nil, scope: nil, filters: nil)
66
+ @access.authorize!(:index)
67
+ relation, named = filtered_relation(search: search, scope: scope, filters: filters)
68
+ total = relation.except(:order).limit(COUNT_LIMIT + 1).count
69
+ CountResult.new([ total, COUNT_LIMIT ].min, total > COUNT_LIMIT, named&.name&.to_s)
70
+ end
71
+
72
+ # Coerces each wire value through the field's scalar contract. Values are
73
+ # bound by Arel; no operator, column or SQL text comes from the request.
74
+ def self.filter_value(field, operator, raw, time_zone:)
75
+ raise AccessDenied, "INVALID_QUERY" unless field.filter_operators.include?(operator.to_sym) && FILTER_VALUE.call(raw)
76
+ value = field.coerce(raw, time_zone: time_zone)
77
+ raise AccessDenied, "INVALID_QUERY" if value.nil?
78
+ value
79
+ rescue ArgumentError
80
+ raise AccessDenied, "INVALID_QUERY", cause: nil
81
+ end
82
+
83
+ private
84
+
85
+ def sorted(relation, sort, direction)
86
+ settings = @resource.index
87
+ table = @resource.model_class.arel_table
88
+ primary = @resource.model_class.primary_key
89
+ sortable = @access.fields(:sort)
90
+ field = if sort.present?
91
+ sortable.find { |entry| entry.name.to_s == sort } || raise(AccessDenied, "INVALID_QUERY")
92
+ elsif settings.default_sort
93
+ # An ungranted default would reveal order on a hidden basis; skip it.
94
+ direction = settings.default_direction
95
+ sortable.find { |entry| entry.name == settings.default_sort }
96
+ end
97
+ if field
98
+ # Empty values sort last in both directions on every database (RAA-028).
99
+ relation = relation.reorder(table[field.column].public_send(direction).nulls_last)
100
+ relation = relation.order(table[primary].asc) unless field.column == primary
101
+ else
102
+ relation = relation.reorder(table[primary].asc)
103
+ end
104
+ [ relation, field, direction ]
105
+ end
106
+
107
+ def filtered_relation(search:, scope:, filters:)
108
+ # Leading and trailing spaces are never part of the term (RAA-021).
109
+ search = search.strip if search.is_a?(String) && search.valid_encoding?
110
+ unless search.nil? || search.is_a?(String) && search.valid_encoding? && (search.encoding == Encoding::UTF_8 || search.ascii_only?) && search.length <= 200 && !search.match?(/[\x00-\x1f\x7f]/)
111
+ raise AccessDenied, "INVALID_QUERY"
112
+ end
113
+ unless scope.nil? || scope == NamedScope::NONE || scope.is_a?(String) && scope.valid_encoding? && scope.ascii_only? && scope.match?(NamedScope::NAME)
114
+ raise AccessDenied, "INVALID_QUERY"
115
+ end
116
+ granted = @access.named_scopes
117
+ unscoped = scope == NamedScope::NONE
118
+ named = if unscoped then nil
119
+ elsif scope.nil? then granted.find(&:default?)
120
+ else granted.find { |definition| definition.name.to_s == scope }
121
+ end
122
+ raise AccessDenied, "INVALID_QUERY" if scope && !unscoped && !named
123
+ relation = @access.relation
124
+ relation = named.apply(relation) if named
125
+ relation = apply_filters(relation, filters)
126
+ if search.present?
127
+ fields = @access.fields(:search)
128
+ raise AccessDenied, "INVALID_QUERY" if fields.empty?
129
+ predicates = fields.map do |field|
130
+ term = ActiveRecord::Base.sanitize_sql_like(search)
131
+ pattern = case field.options[:searchable]
132
+ when :exact then term
133
+ when :contains then "%#{term}%"
134
+ else "#{term}%"
135
+ end
136
+ @resource.model_class.arel_table[field.column].matches(pattern, "\\")
137
+ end
138
+ relation = relation.where(predicates.reduce(&:or))
139
+ end
140
+ [ relation, named ]
141
+ end
142
+
143
+ def apply_filters(relation, filters)
144
+ return relation if filters.nil?
145
+ unless filters.is_a?(Hash) && filters.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } &&
146
+ filters.values.all? { |conditions| conditions.is_a?(Hash) && conditions.any? && conditions.keys.all? { |key| AREL_PREDICATES.key?(key.to_s) } } &&
147
+ filters.values.sum(&:size) <= MAX_FILTER_CONDITIONS
148
+ raise AccessDenied, "INVALID_QUERY"
149
+ end
150
+ granted = @access.fields(:filter).index_by { |field| field.name.to_s }
151
+ table = @resource.model_class.arel_table
152
+ filters.reduce(relation) do |scoped, (name, conditions)|
153
+ field = granted[name.to_s] or raise AccessDenied, "INVALID_QUERY"
154
+ conditions.reduce(scoped) do |inner, (operator, raw)|
155
+ value = self.class.filter_value(field, operator.to_s, raw, time_zone: @access.context.time_zone)
156
+ inner.where(table[field.column].public_send(AREL_PREDICATES.fetch(operator.to_s), value))
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Queries
5
+ # A declared list tab. It can only narrow the current authorized relation:
6
+ # its body builds a separate candidate relation whose primary keys are then
7
+ # intersected with the policy scope, so `unscoped`, `or` or a replaced
8
+ # condition inside the body cannot widen the result.
9
+ class NamedScope
10
+ NAME = /\A[a-z][a-z0-9_]{0,39}\z/
11
+ # List state for "no list tab": the whole policy relation even when a
12
+ # default scope exists (RAA-006). It cannot collide with NAME, and it
13
+ # narrows nothing the policy relation does not already allow.
14
+ NONE = "_none"
15
+ MAX_SCOPES = 20
16
+ attr_reader :name, :label, :body
17
+
18
+ def initialize(name, body = nil, label: nil, default: false)
19
+ unless (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(NAME)
20
+ raise ConfigurationError, "Named scope needs a lowercase identifier of at most 40 characters"
21
+ end
22
+ unless body.nil? || body.is_a?(Proc) && body.lambda? && body.arity.zero?
23
+ raise ConfigurationError, "Named scope body must be a zero-argument lambda"
24
+ end
25
+ raise ConfigurationError, "Named scope default must be boolean" unless [ true, false ].include?(default)
26
+ @name = name.to_sym
27
+ @label = Navigation::Settings.caption(label) || Navigation::Settings.caption(@name.to_s.humanize)
28
+ @body = body
29
+ @default = default
30
+ freeze
31
+ end
32
+
33
+ def default? = @default
34
+
35
+ # Returns the policy relation narrowed to this scope. Trusted host Ruby
36
+ # still runs here; writes are prevented and failures stay generic.
37
+ def apply(relation)
38
+ return relation if body.nil?
39
+ model = relation.klass
40
+ primary_key = model.primary_key
41
+ raise ConfigurationError, "Named scopes require a single-column primary key" unless primary_key.is_a?(String)
42
+ candidate = build(model)
43
+ unless candidate.is_a?(ActiveRecord::Relation) && candidate.klass == model
44
+ raise ConfigurationError, "Named scope #{name} must return the resource's ActiveRecord relation"
45
+ end
46
+ if candidate.limit_value || candidate.offset_value || candidate.select_values.any? || candidate.group_values.any? ||
47
+ !candidate.having_clause.empty? || !candidate.from_clause.empty? ||
48
+ candidate.includes_values.any? || candidate.eager_load_values.any? || candidate.preload_values.any?
49
+ raise ConfigurationError, "Named scope #{name} must be a filter relation without limit, offset, select, group, having, from or eager loading"
50
+ end
51
+ relation.where(primary_key => candidate.unscope(:order).select(model.arel_table[primary_key]))
52
+ end
53
+
54
+ private
55
+
56
+ def build(model)
57
+ ActiveRecord::Base.while_preventing_writes { model.all.instance_exec(&body) }
58
+ rescue ConfigurationError
59
+ raise
60
+ rescue StandardError
61
+ raise ConfigurationError, "Named scope #{name} failed", cause: nil
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # The optimistic version a form, intent, selection or work item carries for
5
+ # one record. Models with Rails optimistic locking use lock_version. Other
6
+ # models use updated_at, as whole microseconds since the epoch, compared
7
+ # after the record is loaded with a row lock inside the write transaction.
8
+ module RecordVersion
9
+ STRATEGIES = %i[lock_version updated_at].freeze
10
+
11
+ module_function
12
+
13
+ # :lock_version, :updated_at or nil when the model cannot carry a version.
14
+ def strategy(model)
15
+ return :lock_version if model.locking_enabled? && model.locking_column == "lock_version"
16
+ column = model.columns_hash["updated_at"]
17
+ return :updated_at if column && %i[datetime timestamp].include?(column.type) && model.record_timestamps
18
+ nil
19
+ end
20
+
21
+ def supported?(model)
22
+ !strategy(model).nil? && !model.composite_primary_key?
23
+ end
24
+
25
+ # A nonnegative Integer. A record whose updated_at is still NULL is
26
+ # version 0 until its first write sets it.
27
+ def of(record)
28
+ case strategy(record.class)
29
+ when :lock_version then record.lock_version
30
+ when :updated_at
31
+ time = record.read_attribute("updated_at")
32
+ time.nil? ? 0 : (time.to_r * 1_000_000).to_i
33
+ else raise ConfigurationError, "Writes require lock_version or an updated_at timestamp"
34
+ end
35
+ end
36
+
37
+ # The column a resource must never let a person write directly.
38
+ def column(model) = strategy(model)&.to_s
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Maps stable slugs to explicit reloadable resources, never arbitrary constants.
5
+ class Registry
6
+ RESERVED = %w[assistant audit dashboards exports imports jobs selections settings doctor assets search mcp].freeze
7
+
8
+ def initialize(names)
9
+ @entries = {}
10
+ names.each do |name|
11
+ resource = name.constantize
12
+ raise ConfigurationError, "#{name} must inherit Resource" unless resource < Resource
13
+ key = resource.key.to_s
14
+ if !key.match?(/\A[a-z][a-z0-9_]*\z/) || RESERVED.include?(key)
15
+ raise ConfigurationError, "Invalid or reserved resource key #{key}"
16
+ end
17
+ raise ConfigurationError, "Duplicate resource key #{key}" if @entries.key?(key)
18
+ @entries[key] = resource.validate!
19
+ end
20
+ # Targets resolve at boot without loading models; reflection agreement
21
+ # is checked on first use and by doctor.
22
+ @entries.each_value do |resource|
23
+ resource.fields.each do |field|
24
+ next unless Field::REFERENCE_TYPES.include?(field.scalar_type) && !@entries.key?(field.options[:resource].to_s)
25
+ raise ConfigurationError, "#{resource.name} #{field.scalar_type} #{field.name} names unregistered resource #{field.options[:resource]}"
26
+ end
27
+ resource.has_many_declarations.each do |panel|
28
+ child = @entries[panel.resource.to_s]
29
+ via = child&.fields&.find { |field| field.name == panel.via(resource) }
30
+ unless via&.scalar_type == :belongs_to
31
+ raise ConfigurationError, "#{resource.name} has_many #{panel.name} needs registered resource #{panel.resource} with belongs_to #{panel.via(resource)}"
32
+ end
33
+ end
34
+ end
35
+ @entries.freeze
36
+ freeze
37
+ end
38
+
39
+ def fetch(key)
40
+ @entries.fetch(key.to_s) { raise AccessDenied }
41
+ end
42
+
43
+ def resources = @entries.values
44
+ end
45
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Redacts admin query values before Rails logs the initial request URL.
5
+ # The documented mount name is required; its path can be customized/reloaded.
6
+ class RequestLogFilter
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ helpers = Rails.application.routes.url_helpers
13
+ if helpers.respond_to?(:rails_ai_admin_path)
14
+ prefix = helpers.rails_ai_admin_path(script_name: "").delete_suffix("/")
15
+ path = env["PATH_INFO"].to_s
16
+ if path == prefix || path.start_with?("#{prefix}/")
17
+ filter = /\A(?!(?:controller|action|resource|id|format)\z)/
18
+ env["action_dispatch.parameter_filter"] = Array(env["action_dispatch.parameter_filter"]) + [ filter ]
19
+ end
20
+ end
21
+ @app.call(env)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Describes explicit exposure of a host model. No attributes are auto-published.
5
+ class Resource
6
+ class << self
7
+ def inherited(child)
8
+ super
9
+ child.instance_variable_set(:@fields, {})
10
+ child.instance_variable_set(:@scopes, {})
11
+ child.instance_variable_set(:@index, Presentation::IndexSettings.new)
12
+ child.instance_variable_set(:@disabled_actions, [])
13
+ child.instance_variable_set(:@operations, {})
14
+ child.instance_variable_set(:@operation_scopes, {})
15
+ child.instance_variable_set(:@surface_operations, [])
16
+ child.instance_variable_set(:@assistant_operations, [])
17
+ child.instance_variable_set(:@navigation, Navigation::Settings.new)
18
+ child.instance_variable_set(:@has_many, {})
19
+ child.instance_variable_set(:@dashboard, [])
20
+ child.instance_variable_set(:@assistant_changes, [])
21
+ end
22
+
23
+ def model(name = nil)
24
+ @model_name = name if name
25
+ @model_name
26
+ end
27
+
28
+ def key(value = nil)
29
+ @key = value.to_sym if value
30
+ @key
31
+ end
32
+
33
+ def label(value = nil)
34
+ @label = value if value
35
+ @label || key.to_s.humanize
36
+ end
37
+
38
+ def title(value = nil)
39
+ @title = value.to_sym if value
40
+ @title
41
+ end
42
+
43
+ # Names an explicit optional policy for this resource, independent of aliases.
44
+ def policy(name = nil)
45
+ @policy_name = name if name
46
+ @policy_name
47
+ end
48
+
49
+ # Cosmetic configuration never grants or revokes direct resource access.
50
+ def navigation(value = :read, **options)
51
+ return @navigation if value == :read && options.empty?
52
+ unless value == :read || value == false && options.empty?
53
+ raise ConfigurationError, "Use navigation options or navigation false"
54
+ end
55
+ @navigation = Navigation::Settings.new(**(value == false ? { hidden: true } : options))
56
+ rescue ArgumentError
57
+ raise ConfigurationError, "Unknown navigation option", cause: nil
58
+ end
59
+
60
+ def readonly(value = true)
61
+ @readonly = value
62
+ end
63
+
64
+ def readonly? = @readonly == true
65
+
66
+ def disable(*actions)
67
+ @disabled_actions.concat(actions.map(&:to_sym))
68
+ end
69
+
70
+ def action_enabled?(action)
71
+ action = action.to_sym
72
+ known = %i[index show create update destroy].include?(action) || @operations.key?(action)
73
+ known && !@disabled_actions.include?(action) && !(readonly? && (%i[create update destroy].include?(action) || @operations.key?(action)))
74
+ end
75
+
76
+ # surface: true also allows reviewing a native member intent in an
77
+ # admin-owned rails_tool_views Surface; assistant: true further lets an
78
+ # assistant propose (never execute) it through that Surface. Neither adds
79
+ # authority: the current policy and a human confirmation still decide.
80
+ def operation(key, handler:, scope: :member, confirmation: :required, surface: false, assistant: false)
81
+ unless key.to_s.match?(/\A[a-z][a-z0-9_]*\z/) && !%w[index show create update destroy new edit].include?(key.to_s)
82
+ raise ConfigurationError, "Custom operation needs a distinct stable key"
83
+ end
84
+ unless handler.is_a?(String) && handler.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) && %i[member collection].include?(scope) && confirmation == :required
85
+ raise ConfigurationError, "Use a handler class-name string, member/collection scope and required confirmation"
86
+ end
87
+ raise ConfigurationError, "Duplicate operation #{key}" if @operations.key?(key.to_sym)
88
+ raise ConfigurationError, "Operation surface must be boolean and is only for member operations" unless [ true, false ].include?(surface) && (!surface || scope == :member)
89
+ raise ConfigurationError, "Operation assistant must be boolean and requires surface: true" unless [ true, false ].include?(assistant) && (!assistant || surface)
90
+ @operations[key.to_sym] = handler.freeze
91
+ @operation_scopes[key.to_sym] = scope
92
+ @surface_operations << key.to_sym if surface
93
+ @assistant_operations << key.to_sym if assistant
94
+ end
95
+
96
+ # A readonly child list on the show page. The child resource's own policy,
97
+ # index grant and a readable belongs_to field (via) decide what appears;
98
+ # declaring it grants nothing and adds no write path.
99
+ def has_many(name, resource: name, via: nil, label: nil, limit: 10)
100
+ definition = Presentation::AssociationPanel::Definition.build(name, resource: resource, via: via, label: label, limit: limit)
101
+ raise ConfigurationError, "Duplicate has_many #{definition.name}" if @has_many.key?(definition.name)
102
+ raise ConfigurationError, "A resource declares at most #{Presentation::AssociationPanel::MAX_PANELS} has_many panels" if @has_many.size >= Presentation::AssociationPanel::MAX_PANELS
103
+ @has_many[definition.name] = definition
104
+ end
105
+
106
+ # A readonly panel for the one child record that points back here (a
107
+ # model has_one). The same grants as has_many apply.
108
+ def has_one(name, resource: name.to_s.pluralize, via: nil, label: nil)
109
+ definition = Presentation::AssociationPanel::Definition.build(name, resource: resource, via: via, label: label, limit: 1, single: true)
110
+ raise ConfigurationError, "Duplicate association panel #{definition.name}" if @has_many.key?(definition.name)
111
+ raise ConfigurationError, "A resource declares at most #{Presentation::AssociationPanel::MAX_PANELS} association panels" if @has_many.size >= Presentation::AssociationPanel::MAX_PANELS
112
+ @has_many[definition.name] = definition
113
+ end
114
+
115
+ def has_many_declarations = @has_many.values
116
+
117
+ # Native changes the admin assistant may propose. Each proposal is shown
118
+ # as a review the human confirms; it runs with the human's own grants.
119
+ def assistant_changes(*actions)
120
+ return @assistant_changes if actions.empty?
121
+ actions = actions.map { |action| action.to_s.to_sym }
122
+ raise ConfigurationError, "assistant_changes accepts :create, :update and :destroy" unless (actions - %i[create update destroy]).empty?
123
+ @assistant_changes = (@assistant_changes | actions).freeze
124
+ end
125
+
126
+ def assistant_change?(action) = @assistant_changes.include?(action.to_s.to_sym) && action_enabled?(action)
127
+
128
+ def operation_declarations = @operations
129
+ def surface_operation?(key) = @surface_operations.include?(key.to_sym)
130
+ def assistant_operation?(key) = @assistant_operations.include?(key.to_sym)
131
+ def assistant_operations = @assistant_operations
132
+ def operation_scopes = @operation_scopes
133
+
134
+ def fields(&block)
135
+ FieldBuilder.new(@fields, source: self).instance_eval(&block) if block
136
+ @fields.values
137
+ end
138
+
139
+ # Overview widgets: metric counts over a list tab and daily trends over
140
+ # a date field. They read through the same grants as the list.
141
+ def dashboard(&block)
142
+ return @dashboard unless block
143
+ builder = Presentation::Dashboard::Builder.new
144
+ begin
145
+ builder.instance_eval(&block)
146
+ rescue NoMethodError => error
147
+ raise ConfigurationError, "Unknown dashboard option #{error.name}", cause: nil
148
+ end
149
+ @dashboard = builder.widgets.freeze
150
+ end
151
+
152
+ # Static index table presentation: column order, default sort, page size
153
+ # and count strategy. It is not part of operation or Selection identity.
154
+ def index(&block)
155
+ return @index unless block
156
+ raise ConfigurationError, "Index settings are declared twice" if @index_declared
157
+ builder = Presentation::IndexBuilder.new
158
+ begin
159
+ builder.instance_eval(&block)
160
+ rescue NoMethodError => error
161
+ raise ConfigurationError, "Unknown index option #{error.name}", cause: nil
162
+ end
163
+ @index = Presentation::IndexSettings.new(**builder.options)
164
+ @index_declared = true
165
+ @index
166
+ end
167
+
168
+ # Named list tabs narrow the policy relation; they never grant or widen it.
169
+ def scopes(&block)
170
+ ScopeBuilder.new(@scopes).instance_eval(&block) if block
171
+ @scopes.values
172
+ end
173
+
174
+ def model_class
175
+ raise ConfigurationError, "model must be a constant name string" unless model.is_a?(String)
176
+ klass = model.constantize
177
+ raise ConfigurationError, "model must inherit ActiveRecord::Base" unless klass < ActiveRecord::Base
178
+ klass
179
+ end
180
+
181
+ def validate!
182
+ raise ConfigurationError, "model must be a constant name string" unless model.is_a?(String)
183
+ if policy && (!policy.is_a?(String) || !policy.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/))
184
+ raise ConfigurationError, "policy must be a constant name string"
185
+ end
186
+ raise ConfigurationError, "A resource must declare fields and a title" if fields.empty? || !title || !@fields.key?(title)
187
+ raise ConfigurationError, "A resource cannot declare lock_version as a field" if @fields.key?(:lock_version)
188
+ raise ConfigurationError, "A password field cannot be the title" if @fields[title].scalar_type == :password
189
+ Fields::DependencyGraph.validate!(fields)
190
+ index.validate!(fields)
191
+ Presentation::Dashboard.validate!(self)
192
+ # Fail at registration rather than on every menu render; explicit labels
193
+ # were already bounded when declared.
194
+ Navigation::Settings.caption(label) unless navigation.hidden || navigation.label
195
+ @policy_name&.freeze
196
+ @fields.freeze
197
+ @scopes.freeze
198
+ @disabled_actions.freeze
199
+ @operations.freeze
200
+ @operation_scopes.freeze
201
+ @surface_operations.freeze
202
+ @has_many.freeze
203
+ @assistant_operations.freeze
204
+ self
205
+ end
206
+ end
207
+
208
+ # Collects named list scopes. Bodies are not evaluated at declaration time.
209
+ class ScopeBuilder
210
+ def initialize(scopes)
211
+ @scopes = scopes
212
+ end
213
+
214
+ def scope(name, body = nil, label: nil, default: false)
215
+ definition = Queries::NamedScope.new(name, body, label: label, default: default)
216
+ raise ConfigurationError, "Duplicate named scope #{definition.name}" if @scopes.key?(definition.name)
217
+ raise ConfigurationError, "A resource declares at most #{Queries::NamedScope::MAX_SCOPES} named scopes" if @scopes.size >= Queries::NamedScope::MAX_SCOPES
218
+ raise ConfigurationError, "A resource declares at most one default named scope" if definition.default? && @scopes.values.any?(&:default?)
219
+ @scopes[definition.name] = definition
220
+ end
221
+ end
222
+
223
+ # Collects field declarations without consulting the database at boot.
224
+ class FieldBuilder
225
+ # source is the declaring Resource, consulted lazily for column limits.
226
+ def initialize(fields, source: nil)
227
+ @fields = fields
228
+ @source = source
229
+ end
230
+
231
+ def field(name, type:, **options)
232
+ raise ConfigurationError, "Duplicate field #{name}" if @fields.key?(name.to_sym)
233
+ @fields[name.to_sym] = RailsAiAdmin.field_registry.build(name, type: type, source: @source, **options)
234
+ end
235
+
236
+ def virtual(name, type:, depends_on:, compute:, **options)
237
+ field(name, type: type, compute: compute, depends_on: depends_on, **options)
238
+ end
239
+
240
+ def readonly(name, type: :text, **options)
241
+ field(name, type: type, **options.merge(readonly: true))
242
+ end
243
+
244
+ Field::TYPES.each do |type|
245
+ define_method(type) { |name, **options| field(name, type: type, **options) }
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ # Stable across processes, but changes when a model's record IDs change meaning.
5
+ module ResourceIdentity
6
+ def self.call(resource)
7
+ model = resource.model_class
8
+ primary_key = model.primary_key
9
+ column = primary_key.is_a?(String) ? model.columns_hash[primary_key] : nil
10
+ { model: resource.model, table: model.table_name, primary_key: primary_key,
11
+ column_type: column&.type, sql_type: column&.sql_type,
12
+ attribute_type: primary_key.is_a?(String) ? model.type_for_attribute(primary_key).type : nil }
13
+ end
14
+ end
15
+ end