rails_ai_admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,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
|