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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Authorization
5
+ # Intersects host decisions with declared exposure on every use.
6
+ class Access
7
+ attr_reader :context, :resource
8
+
9
+ def initialize(context, resource)
10
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
11
+ @context, @resource = context, resource
12
+ @adapter = RailsAiAdmin.configuration.authorization_adapter.constantize.new
13
+ end
14
+
15
+ def authorize!(action, record = nil)
16
+ raise AccessDenied unless resource.action_enabled?(action) && @adapter.allowed?(context: context, action: action.to_sym, resource: resource, record: record) == true
17
+ true
18
+ end
19
+
20
+ def relation
21
+ relation = @adapter.scope(context: context, resource: resource, relation: resource.model_class.all)
22
+ unless relation.is_a?(ActiveRecord::Relation) && relation.klass == resource.model_class
23
+ raise ConfigurationError, "Policy scope must return the resource's ActiveRecord relation"
24
+ end
25
+ if relation.limit_value || relation.offset_value || relation.select_values.any? || relation.group_values.any? || !relation.having_clause.empty? || !relation.from_clause.empty?
26
+ raise ConfigurationError, "Policy scope must be a complete filter relation without limit, offset, select, group, having or from"
27
+ end
28
+ relation
29
+ end
30
+
31
+ def find!(id, action: :show, lock: false)
32
+ scoped = relation
33
+ scoped = scoped.lock if lock
34
+ record = scoped.find(id)
35
+ authorize!(action, record)
36
+ record
37
+ rescue ActiveRecord::RecordNotFound
38
+ raise AccessDenied
39
+ end
40
+
41
+ # "1abc", "01" and " 1" would otherwise be cast to record 1 (RAA-022).
42
+ # Pages address a record only by the primary key's canonical text; the
43
+ # trusted Runner API still canonicalizes through the scoped lookup.
44
+ def canonical_id?(id)
45
+ return id.positive? if id.is_a?(Integer)
46
+ return false unless id.is_a?(String) && id.valid_encoding? && id.bytesize.between?(1, 150) && !id.match?(/[[:cntrl:]]|[[:space:]]/)
47
+ case resource.model_class.type_for_attribute(resource.model_class.primary_key).type
48
+ when :integer then id.match?(/\A[1-9][0-9]{0,18}\z/)
49
+ when :uuid then id.match?(/\A[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\z/)
50
+ else true
51
+ end
52
+ end
53
+
54
+ VIEWS = %i[index show create edit].freeze
55
+
56
+ def fields(action, record = nil, operation: nil, view: nil)
57
+ view = write_view(operation, view) if action == :write
58
+ granted = permission_fields(action, record, operation: operation, view: view)
59
+ action == :read && view ? granted.select { |field| field.visible_on?(view) } : granted
60
+ end
61
+
62
+ # only: optional field names to compute (for example index columns plus the
63
+ # title); it can narrow but never add to the granted, visible fields.
64
+ def project(record, view: nil, only: nil)
65
+ authorize!(:show, record)
66
+ granted = permission_fields(:read, record, view: view)
67
+ visible = view ? granted.select { |field| field.visible_on?(view) } : granted
68
+ visible = visible.select { |field| only.include?(field.name) } if only
69
+ Fields::Projection.new(self, record, granted).values(fields: visible)
70
+ end
71
+
72
+ # Declared named scopes intersected with the current independent grant, in
73
+ # declaration order. This performs no domain query.
74
+ def named_scopes
75
+ declared = resource.scopes
76
+ return [] if declared.empty?
77
+ names = @adapter.respond_to?(:named_scopes) ? @adapter.named_scopes(context: context, resource: resource) : []
78
+ unless names.is_a?(Array) && names.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
79
+ raise ConfigurationError, "Named scope grants must be an array of names"
80
+ end
81
+ names = names.map(&:to_sym)
82
+ declared.select { |definition| names.include?(definition.name) }
83
+ end
84
+
85
+ def authorize_selection!
86
+ authorize!(:index)
87
+ allowed = @adapter.respond_to?(:select_records?) && @adapter.select_records?(context: context, resource: resource) == true
88
+ raise AccessDenied unless allowed
89
+ true
90
+ end
91
+
92
+ # The model may see this resource only with the independent assistant
93
+ # grant plus the ordinary index grant; tools still apply every read grant.
94
+ def authorize_assistant!
95
+ authorize!(:index)
96
+ allowed = @adapter.respond_to?(:assistant?) && @adapter.assistant?(context: context, resource: resource) == true
97
+ raise AccessDenied unless allowed
98
+ true
99
+ end
100
+
101
+ # Scalar declarations cannot turn arbitrary model methods into read actions.
102
+ # Host overrides of real attribute getters remain trusted application code.
103
+ def read_value(record, field, view: nil)
104
+ unless resource.fields.any? { |candidate| candidate.equal?(field) }
105
+ raise ConfigurationError, "Read fields must belong to the current resource"
106
+ end
107
+ authorize!(:show, record)
108
+ granted = permission_fields(:read, record, view: view)
109
+ raise AccessDenied if view && !field.visible_on?(view)
110
+ Fields::Projection.new(self, record, granted).value(field)
111
+ end
112
+
113
+ def read_attribute(record, field)
114
+ raise AccessDenied if field.hidden? || field.scalar_type == :password
115
+ raise ConfigurationError, "Computed fields do not invoke model methods" if field.virtual?
116
+ return read_file(record, field) if field.scalar_type == :file
117
+ return read_files(record, field) if field.scalar_type == :files
118
+ return read_rich_text(record, field) if field.scalar_type == :rich_text
119
+ return read_references(record, field) if field.scalar_type == :references
120
+ name = field.column
121
+ primary_key = record.class.primary_key
122
+ id_alias = name == "id" && primary_key.is_a?(String) && record.has_attribute?(primary_key)
123
+ unless record.has_attribute?(name) || id_alias
124
+ raise ConfigurationError, "Scalar fields must name model attributes"
125
+ end
126
+ value = ActiveRecord::Base.while_preventing_writes { record.public_send(name) }
127
+ # References travel as opaque key text, matching their input contract.
128
+ field.scalar_type == :belongs_to && !value.nil? ? value.to_s : value
129
+ rescue ActiveRecord::ReadOnlyError
130
+ raise ConfigurationError, "Scalar attribute getters must not write", cause: nil
131
+ end
132
+
133
+ # Shared title contract for native pages, fixed selections and job entries.
134
+ def record_label(record, values: nil, view: :show)
135
+ fallback = "Record ##{record.id}"
136
+ authorize!(:show, record)
137
+ field = fields(:read, record, view: view).find { |candidate| candidate.name == resource.title }
138
+ return fallback unless field
139
+ return fallback if values && !values.key?(field.name.to_s)
140
+ value = values ? values.fetch(field.name.to_s) : read_value(record, field, view: view)
141
+ field.format(value, time_zone: context.time_zone).truncate(120)
142
+ rescue AccessDenied
143
+ fallback
144
+ end
145
+
146
+ def operation_fields(operation, record = nil)
147
+ definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
148
+ return [] unless definition.custom?
149
+ names = @adapter.respond_to?(:operation_fields) ? @adapter.operation_fields(context: context, resource: resource, operation: operation.to_sym, record: record) : []
150
+ raise ConfigurationError, "Operation fields must be an array" unless names.is_a?(Array)
151
+ names = names.map(&:to_sym)
152
+ definition.handler_class.input.select { |field| names.include?(field.name) }
153
+ end
154
+
155
+ def authorize_export!
156
+ authorize!(:index)
157
+ allowed = @adapter.respond_to?(:export_records?) && @adapter.export_records?(context: context, resource: resource) == true
158
+ raise AccessDenied unless allowed
159
+ true
160
+ end
161
+
162
+ def authorize_import!
163
+ authorize!(:create, relation.new)
164
+ allowed = @adapter.respond_to?(:import_records?) && @adapter.import_records?(context: context, resource: resource) == true
165
+ raise AccessDenied unless allowed
166
+ true
167
+ end
168
+
169
+ def authorize_audit_log!
170
+ allowed = @adapter.respond_to?(:view_audit_log?) && @adapter.view_audit_log?(context: context, resource: resource) == true
171
+ raise AccessDenied unless allowed
172
+ true
173
+ end
174
+
175
+ def authorize_history!(record)
176
+ authorize!(:show, record)
177
+ allowed = @adapter.respond_to?(:view_history?) && @adapter.view_history?(context: context, resource: resource, record: record) == true
178
+ raise AccessDenied unless allowed
179
+ true
180
+ end
181
+
182
+ def authorize_delivery!(operation, work_kind, record)
183
+ allowed = @adapter.respond_to?(:deliver_work?) && @adapter.deliver_work?(context: context, resource: resource,
184
+ operation: operation.to_sym, work_kind: work_kind, record: record) == true
185
+ raise AccessDenied unless allowed
186
+ true
187
+ end
188
+
189
+ def authorize_cancel!(operation)
190
+ allowed = @adapter.respond_to?(:cancel_operation?) && @adapter.cancel_operation?(context: context, resource: resource, operation: operation.to_sym) == true
191
+ raise AccessDenied unless allowed
192
+ true
193
+ end
194
+
195
+ def authorize_receipt!(operation, record)
196
+ allowed = @adapter.respond_to?(:read_operation_receipt?) && @adapter.read_operation_receipt?(context: context,
197
+ resource: resource, operation: operation.to_sym, record: record) == true
198
+ raise AccessDenied unless allowed
199
+ true
200
+ end
201
+ # The saved attachment rows of a files field, oldest first. Pending
202
+ # attachments of an unsaved change are not included.
203
+ def stored_attachments(record, field)
204
+ attachment(record, field)
205
+ ::ActiveStorage::Attachment.where(record_type: record.class.polymorphic_name, record_id: record.id, name: field.column).order(:id)
206
+ end
207
+
208
+ # The Active Storage proxy for a file (has_one_attached) or files
209
+ # (has_many_attached) field.
210
+ def attachment(record, field)
211
+ macro = field.scalar_type == :files ? :has_many_attached : :has_one_attached
212
+ reflection = record.class.respond_to?(:reflect_on_attachment) && record.class.reflect_on_attachment(field.column)
213
+ raise ConfigurationError, "#{field.scalar_type} fields must name a #{macro} attachment" unless reflection && reflection.macro == macro
214
+ ActiveRecord::Base.while_preventing_writes { record.public_send(field.column) }
215
+ end
216
+
217
+ private
218
+
219
+ # The stored Action Text HTML, or nil. Pages sanitize it when they show it.
220
+ def read_rich_text(record, field)
221
+ unless record.class.reflect_on_association("rich_text_#{field.column}")
222
+ raise ConfigurationError, "rich_text fields must name a has_rich_text attribute"
223
+ end
224
+ content = ActiveRecord::Base.while_preventing_writes { record.public_send(field.column)&.body }
225
+ html = content&.to_html.to_s
226
+ html.match?(Field::BLANK) ? nil : String.new(html)
227
+ end
228
+
229
+ # The associated keys as sorted text. Targets the actor cannot see stay
230
+ # keys; pages label them through the target's own grants.
231
+ def read_references(record, field)
232
+ reflection = record.class.reflect_on_association(field.name)
233
+ unless reflection&.collection? && record.respond_to?(field.column)
234
+ raise ConfigurationError, "references fields must name a has_many or has_and_belongs_to_many association"
235
+ end
236
+ ids = ActiveRecord::Base.while_preventing_writes { record.public_send(field.column) }
237
+ ids.map { |id| id.to_s.freeze }.uniq.sort.freeze
238
+ end
239
+
240
+ # At most 100 files, oldest first, each with its attachment id.
241
+ def read_files(record, field)
242
+ attachments = stored_attachments(record, field).includes(:blob).limit(Field::MAX_REFERENCES).to_a
243
+ attachments.map { |item| Fields::FileValue.from(item.blob, id: item.id) }.freeze
244
+ end
245
+
246
+ def read_file(record, field)
247
+ attached = attachment(record, field)
248
+ attached.attached? ? Fields::FileValue.from(attached.blob) : nil
249
+ end
250
+
251
+ def write_view(operation, view)
252
+ expected = operation.to_sym == :create ? :create : :edit if operation
253
+ expected ||= :edit
254
+ raise ConfigurationError, "Write presentation must match its lifecycle" if view && view != expected
255
+ expected
256
+ end
257
+
258
+ def permission_fields(action, record, operation: nil, view: nil)
259
+ raise ConfigurationError, "Unknown presentation view" if view && !VIEWS.include?(view)
260
+ names = @adapter.fields(context: context, resource: resource, action: action, record: record)
261
+ unless names.is_a?(Array) && names.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
262
+ raise ConfigurationError, "Policy fields must be an array of names"
263
+ end
264
+ names = names.map(&:to_sym)
265
+ selected = resource.fields.select do |field|
266
+ !field.hidden? && names.include?(field.name) &&
267
+ # A password is write-only: no read grant can reveal it.
268
+ (action == :write || field.scalar_type != :password) &&
269
+ (action != :write || field.name.to_s != resource.model_class.primary_key) &&
270
+ (action != :write || field.writable?(operation || :update)) &&
271
+ (action != :search || !field.virtual? && field.options[:searchable]) &&
272
+ (action != :sort || !field.virtual? && field.options[:sortable]) &&
273
+ (action != :filter || !field.virtual? && field.filter_operators.any?)
274
+ end
275
+ mask = presentation_mask(record, view) if view
276
+ selected = selected.select { |field| mask.include?(field.name) } if mask
277
+ action == :read ? Fields::DependencyGraph.readable(selected) : selected
278
+ end
279
+
280
+ def presentation_mask(record, view)
281
+ return unless @adapter.respond_to?(:presentation_fields, true)
282
+ raise ConfigurationError, "Presentation policy must be public" unless @adapter.respond_to?(:presentation_fields)
283
+ names = ActiveRecord::Base.while_preventing_writes { @adapter.presentation_fields(context: context, resource: resource, view: view, record: record) }
284
+ return if names.nil?
285
+ unless names.is_a?(Array) && names.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
286
+ raise ConfigurationError, "Presentation policy must return nil or field names"
287
+ end
288
+ names.map(&:to_sym)
289
+ rescue ConfigurationError
290
+ raise
291
+ rescue StandardError
292
+ raise ConfigurationError, "Presentation policy failed", cause: nil
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Authorization
5
+ # Default-deny policy interface. Hosts explicitly implement every permission.
6
+ class Adapter
7
+ def scope(context:, resource:, relation:)
8
+ relation.none
9
+ end
10
+
11
+ def allowed?(context:, action:, resource:, record: nil)
12
+ false
13
+ end
14
+
15
+ # action is :read, :write, :search, :sort or :filter; no implicit grants.
16
+ # Filtering reveals values through membership, so it is never implied by read.
17
+ def fields(context:, resource:, action:, record: nil)
18
+ []
19
+ end
20
+
21
+ # Optional per-view narrowing, never a replacement for read/write grants.
22
+ # nil adds no restriction; [] hides every field in that view.
23
+ def presentation_fields(context:, resource:, view:, record: nil)
24
+ nil
25
+ end
26
+
27
+ # Custom operation inputs are independent of ordinary attribute write grants.
28
+ def operation_fields(context:, resource:, operation:, record: nil)
29
+ []
30
+ end
31
+
32
+ # Delivery authorizes a committed fact, not re-execution of its command.
33
+ def deliver_work?(context:, resource:, operation:, work_kind:, record:)
34
+ false
35
+ end
36
+
37
+ def read_operation_receipt?(context:, resource:, operation:, record:)
38
+ false
39
+ end
40
+
41
+ def cancel_operation?(context:, resource:, operation:)
42
+ false
43
+ end
44
+
45
+ # Named list scopes (declared tabs) this actor may use. A scope predicate can
46
+ # reveal facts about fields the actor cannot read, so it is granted apart
47
+ # from index/read/search/sort.
48
+ def named_scopes(context:, resource:)
49
+ []
50
+ end
51
+
52
+ # Capturing/viewing a fixed selection is independent of list/read/write.
53
+ def select_records?(context:, resource:)
54
+ false
55
+ end
56
+
57
+ # Exposes this resource to the admin assistant's tools. Human read grants
58
+ # still apply to every tool call; this grant only decides whether the
59
+ # model may see the resource at all. The assistant is available to an
60
+ # actor when at least one resource grants it.
61
+ def assistant?(context:, resource:)
62
+ false
63
+ end
64
+
65
+ # Downloads the current list as CSV. Rows, columns and values still come
66
+ # from the index, show and read grants; this grant only adds the button.
67
+ def export_records?(context:, resource:)
68
+ false
69
+ end
70
+
71
+ # Creates records from an uploaded CSV file. Each row still needs the
72
+ # create grant, write field grants and validations.
73
+ def import_records?(context:, resource:)
74
+ false
75
+ end
76
+
77
+ # Lists this resource's audit events on the audit log page, across
78
+ # records and actors of the current tenant. Field names still follow the
79
+ # read and write grants.
80
+ def view_audit_log?(context:, resource:)
81
+ false
82
+ end
83
+
84
+ # Shows a record's change history (who changed it, when, which fields).
85
+ # Field names are filtered to the actor's current read and write grants;
86
+ # this grant only decides whether the page exists for the record.
87
+ def view_history?(context:, resource:, record:)
88
+ false
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Authorization
5
+ # Optional Pundit integration using a separate explicit policy per resource.
6
+ # The policy receives the complete admin Context as its user argument.
7
+ class PunditAdapter < Adapter
8
+ ACTIONS = %i[index show create update destroy].freeze
9
+ FIELD_METHODS = { read: :readable_fields, write: :writable_fields,
10
+ search: :searchable_fields, sort: :sortable_fields, filter: :filterable_fields }.freeze
11
+
12
+ def initialize
13
+ require "pundit"
14
+ unless Gem::Requirement.new(">= 2.5", "< 3").satisfied_by?(Gem.loaded_specs.fetch("pundit").version)
15
+ raise ConfigurationError, "PunditAdapter requires pundit >= 2.5 and < 3"
16
+ end
17
+ rescue LoadError
18
+ raise ConfigurationError, "Add pundit >= 2.5 and < 3 to the host bundle", cause: nil
19
+ end
20
+
21
+ def scope(context:, resource:, relation:)
22
+ klass = policy_class(resource)
23
+ scope = klass && scope_class(klass)
24
+ return relation.none unless scope
25
+ # Pundit's documented explicit Scope constructor/resolve contract avoids
26
+ # guessing a model policy, which would merge resource-alias authority.
27
+ scope.new(context, relation).resolve
28
+ rescue StandardError => error
29
+ policy_error!(resource, error)
30
+ end
31
+
32
+ def allowed?(context:, action:, resource:, record: nil)
33
+ return false unless ACTIONS.include?(action.to_sym) || resource.operation_declarations.key?(action.to_sym)
34
+ klass = policy_class(resource)
35
+ target = record || (action.to_sym == :create ? scope(context: context, resource: resource, relation: resource.model_class.all).new : resource.model_class)
36
+ return false unless klass && klass.new(context, target).respond_to?("#{action}?")
37
+ Pundit.authorize(context, target, "#{action}?", policy_class: klass)
38
+ true
39
+ rescue Pundit::NotAuthorizedError
40
+ false
41
+ rescue StandardError => error
42
+ policy_error!(resource, error)
43
+ end
44
+
45
+ def fields(context:, resource:, action:, record: nil)
46
+ method = FIELD_METHODS[action.to_sym]
47
+ klass = policy_class(resource)
48
+ return [] unless method && klass
49
+ policy = klass.new(context, record || resource.model_class)
50
+ policy.respond_to?(method) ? policy.public_send(method) : []
51
+ rescue StandardError => error
52
+ policy_error!(resource, error)
53
+ end
54
+
55
+ def presentation_fields(context:, resource:, view:, record: nil)
56
+ klass = policy_class(resource)
57
+ return [] unless klass
58
+ policy = klass.new(context, record || resource.model_class)
59
+ return unless policy.respond_to?(:presentation_fields, true)
60
+ raise ConfigurationError, "Presentation policy must be public" unless policy.respond_to?(:presentation_fields)
61
+ policy.presentation_fields(view)
62
+ rescue StandardError => error
63
+ policy_error!(resource, error)
64
+ end
65
+
66
+ # Reports whether an explicit policy and its owned/inherited Scope exist.
67
+ # This checks metadata only; it does not attest any authorization decision.
68
+ def configured?(resource)
69
+ klass = policy_class(resource)
70
+ !!(klass && scope_class(klass))
71
+ rescue StandardError => error
72
+ policy_error!(resource, error)
73
+ end
74
+
75
+ def operation_fields(context:, resource:, operation:, record: nil)
76
+ klass = policy_class(resource)
77
+ return [] unless klass
78
+ policy = klass.new(context, record || resource.model_class)
79
+ policy.respond_to?(:permitted_operation_fields) ? policy.permitted_operation_fields(operation.to_sym) : []
80
+ rescue StandardError => error
81
+ policy_error!(resource, error)
82
+ end
83
+
84
+ def deliver_work?(context:, resource:, operation:, work_kind:, record:)
85
+ klass = policy_class(resource)
86
+ return false unless klass
87
+ policy = klass.new(context, record)
88
+ policy.respond_to?(:deliver_operation_work?) && policy.deliver_operation_work?(operation.to_sym, work_kind.to_s) == true
89
+ rescue StandardError => error
90
+ policy_error!(resource, error)
91
+ end
92
+
93
+ def read_operation_receipt?(context:, resource:, operation:, record:)
94
+ klass = policy_class(resource)
95
+ return false unless klass
96
+ policy = klass.new(context, record || resource.model_class)
97
+ policy.respond_to?(:read_operation_receipt?) && policy.read_operation_receipt?(operation.to_sym) == true
98
+ rescue StandardError => error
99
+ policy_error!(resource, error)
100
+ end
101
+
102
+ def cancel_operation?(context:, resource:, operation:)
103
+ klass = policy_class(resource)
104
+ return false unless klass
105
+ policy = klass.new(context, resource.model_class)
106
+ policy.respond_to?(:cancel_operation?) && policy.cancel_operation?(operation.to_sym) == true
107
+ rescue StandardError => error
108
+ policy_error!(resource, error)
109
+ end
110
+
111
+ def named_scopes(context:, resource:)
112
+ klass = policy_class(resource)
113
+ return [] unless klass
114
+ policy = klass.new(context, resource.model_class)
115
+ policy.respond_to?(:permitted_named_scopes) ? policy.permitted_named_scopes : []
116
+ rescue StandardError => error
117
+ policy_error!(resource, error)
118
+ end
119
+
120
+ def select_records?(context:, resource:)
121
+ klass = policy_class(resource)
122
+ return false unless klass
123
+ policy = klass.new(context, resource.model_class)
124
+ policy.respond_to?(:select_records?) && policy.select_records? == true
125
+ rescue StandardError => error
126
+ policy_error!(resource, error)
127
+ end
128
+
129
+ def export_records?(context:, resource:)
130
+ klass = policy_class(resource)
131
+ return false unless klass
132
+ policy = klass.new(context, resource.model_class)
133
+ policy.respond_to?(:export_records?) && policy.export_records? == true
134
+ rescue StandardError => error
135
+ policy_error!(resource, error)
136
+ end
137
+
138
+ def import_records?(context:, resource:)
139
+ klass = policy_class(resource)
140
+ return false unless klass
141
+ policy = klass.new(context, resource.model_class)
142
+ policy.respond_to?(:import_records?) && policy.import_records? == true
143
+ rescue StandardError => error
144
+ policy_error!(resource, error)
145
+ end
146
+
147
+ def view_audit_log?(context:, resource:)
148
+ klass = policy_class(resource)
149
+ return false unless klass
150
+ policy = klass.new(context, resource.model_class)
151
+ policy.respond_to?(:view_audit_log?) && policy.view_audit_log? == true
152
+ rescue StandardError => error
153
+ policy_error!(resource, error)
154
+ end
155
+
156
+ def view_history?(context:, resource:, record:)
157
+ klass = policy_class(resource)
158
+ return false unless klass
159
+ policy = klass.new(context, record)
160
+ policy.respond_to?(:view_history?) && policy.view_history? == true
161
+ rescue StandardError => error
162
+ policy_error!(resource, error)
163
+ end
164
+
165
+ def assistant?(context:, resource:)
166
+ klass = policy_class(resource)
167
+ return false unless klass
168
+ policy = klass.new(context, resource.model_class)
169
+ policy.respond_to?(:assistant?) && policy.assistant? == true
170
+ rescue StandardError => error
171
+ policy_error!(resource, error)
172
+ end
173
+
174
+ private
175
+
176
+ def policy_class(resource)
177
+ name = resource.policy
178
+ return unless name
179
+ klass = name.safe_constantize
180
+ raise ConfigurationError, "Policy must resolve to a class" if klass && !klass.is_a?(Class)
181
+ klass
182
+ end
183
+
184
+ def scope_class(policy)
185
+ owner = policy.ancestors.take_while { |ancestor| ancestor != Object && ancestor != BasicObject }
186
+ .find { |ancestor| ancestor.is_a?(Class) && ancestor.const_defined?(:Scope, false) }
187
+ owner&.const_get(:Scope, false)
188
+ end
189
+
190
+ def policy_error!(resource, error)
191
+ raise ConfigurationError, "Pundit policy failed for #{resource.key} (#{error.class.name})", cause: nil
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Collections
5
+ # A server-created lease token. Public HTTP execution cannot supply one.
6
+ class Claim
7
+ attr_reader :item_ref, :parent_ref, :operation_run_id, :owner, :generation
8
+ def initialize(item)
9
+ @item_ref, @parent_ref, @operation_run_id = item.id, item.parent_run_id, item.operation_run_id
10
+ @owner, @generation = item.lease_owner, item.generation
11
+ end
12
+
13
+ def context
14
+ run = OperationRun.find(parent_ref)
15
+ @parent_snapshot = run
16
+ ActiveRecord::Base.while_preventing_writes do
17
+ adapter = RailsAiAdmin.configuration.context_adapter&.constantize&.new
18
+ raise AccessDenied, "APPROVAL_INVALIDATED" unless adapter&.respond_to?(:resolve_job)
19
+ current = adapter.resolve_job(actor_ref: run.actor_ref, tenant_ref: run.tenant_ref, security_version: run.security_version)
20
+ unless current.is_a?(Context) && current.actor_ref == run.actor_ref && current.tenant_ref == run.tenant_ref && current.security_version == run.security_version
21
+ raise AccessDenied, "APPROVAL_INVALIDATED"
22
+ end
23
+ current
24
+ end
25
+ rescue AccessDenied
26
+ raise AccessDenied, "APPROVAL_INVALIDATED", cause: nil
27
+ rescue ActiveRecord::ReadOnlyError
28
+ raise ConfigurationError, "Job context resolution must be read-only", cause: nil
29
+ end
30
+
31
+ def payload = @parent_snapshot.input_payload
32
+ def origin = @parent_snapshot.origin
33
+
34
+ def lock!(context)
35
+ @parent = Contract.owned(context, parent_ref, lock: true)
36
+ @item = BatchItem.lock.find_by(id: item_ref, parent_run_id: parent_ref, operation_run_id: operation_run_id)
37
+ verify_live!
38
+ @resource, @definition = Contract.definition(@parent)
39
+ Contract.verify_approved!(@parent, @definition, context)
40
+ begin
41
+ ActiveRecord::Base.while_preventing_writes do
42
+ access = Authorization::Access.new(context, @resource)
43
+ access.authorize!(@parent.operation)
44
+ current = InputSchema.new(@definition.fields(access, nil)).coerce(@parent.input_payload, time_zone: context.time_zone)
45
+ raise AccessDenied unless current.valid? && InputSchema.digest(current.values) == @parent.input_digest
46
+ end
47
+ rescue AccessDenied
48
+ raise AccessDenied, "AUTHORIZATION_REVOKED", cause: nil
49
+ end
50
+ unless @parent.manifest_payload[@item.position] == [ @item.target_id, @item.expected_version ]
51
+ raise AccessDenied, "APPROVAL_INVALIDATED"
52
+ end
53
+ end
54
+
55
+ def validate_child!(run, resource, definition)
56
+ unless resource.equal?(@resource) && definition.scope == :collection && run.parent_run_id == parent_ref &&
57
+ run.selection_id.nil? && run.target_id == @item.target_id && run.expected_version == @item.expected_version &&
58
+ run.actor_ref == @parent.actor_ref && run.tenant_ref == @parent.tenant_ref && run.security_version == @parent.security_version &&
59
+ run.resource_key == @parent.resource_key && run.operation == @parent.operation && run.operation_version == @parent.operation_version &&
60
+ run.input_digest == @parent.input_digest && run.origin == @parent.origin && run.expires_at == @parent.execution_deadline &&
61
+ run.approved_by_ref.nil? && run.confirmed_at.nil? && run.approved_digest.nil?
62
+ raise AccessDenied, "APPROVAL_INVALIDATED"
63
+ end
64
+ end
65
+
66
+ def complete!(run)
67
+ verify_live!
68
+ raise ConfigurationError, "Child receipt did not commit success" unless run.status == "succeeded"
69
+ @item.update!(status: "succeeded", last_code: "OK", completed_at: Time.current)
70
+ Contract.refresh!(@parent)
71
+ # Projection and all metadata writes must finish within the lease.
72
+ # Parent/item locks remain held through the encompassing business commit.
73
+ unless @item.lease_owner == owner && @item.generation == generation && @item.lease_expires_at > Time.current
74
+ raise AccessDenied, "FENCED"
75
+ end
76
+ raise AccessDenied, "EXECUTION_EXPIRED" unless @parent.execution_deadline > Time.current
77
+ end
78
+
79
+ private
80
+
81
+ def verify_live!
82
+ unless @item && @item.status == "running" && @item.lease_owner == owner && @item.generation == generation && @item.lease_expires_at && @item.lease_expires_at > Time.current
83
+ raise AccessDenied, "FENCED"
84
+ end
85
+ raise AccessDenied, "CANCELLED" if @parent.cancellation_requested_at
86
+ raise AccessDenied, "EXECUTION_EXPIRED" unless @parent.execution_deadline && @parent.execution_deadline > Time.current
87
+ raise AccessDenied, "FENCED" unless Contract::ACTIVE.include?(@parent.status)
88
+ end
89
+ end
90
+ end
91
+ end