open-loam 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 +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- metadata +332 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A read-model projection of ONE custom field of ONE record into typed,
|
|
3
|
+
# INDEXED columns — so filtering/sorting/searching on a custom field is
|
|
4
|
+
# index-backed instead of a per-row JSON extraction over the `custom_fields`
|
|
5
|
+
# column (which can't be indexed and full-scans at scale). One row per
|
|
6
|
+
# (record, custom field); maintained by Loam::CustomFieldIndex from the
|
|
7
|
+
# Loam::CustomFields save/destroy hooks. Tenant-scoped.
|
|
8
|
+
#
|
|
9
|
+
# The value is written into the column matching the field's declared type
|
|
10
|
+
# (value_number / value_boolean / value_datetime) AND always into value_text
|
|
11
|
+
# (its string form) for text ops (contains/present) and canonical equality.
|
|
12
|
+
class CustomFieldValue < Loam::TenantRecord
|
|
13
|
+
self.table_name = "loam_custom_field_values"
|
|
14
|
+
|
|
15
|
+
validates :indexable_type, :indexable_id, :field_key, presence: true
|
|
16
|
+
validates :field_key, uniqueness: { scope: %i[indexable_type indexable_id] }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A tenant's dashboard layout: which registered widgets appear, in what order.
|
|
3
|
+
# A manager arranges these on the Dashboard settings screen; the dashboard
|
|
4
|
+
# renders the active ones by position (see Loam::Dashboard). Tenant-scoped and
|
|
5
|
+
# audited.
|
|
6
|
+
class DashboardWidget < Loam::TenantRecord
|
|
7
|
+
self.table_name = "loam_dashboard_widgets"
|
|
8
|
+
|
|
9
|
+
include Loam::Auditable
|
|
10
|
+
|
|
11
|
+
validates :widget_key, presence: true, uniqueness: { scope: :tenant_id }
|
|
12
|
+
|
|
13
|
+
scope :active, -> { where(active: true) }
|
|
14
|
+
scope :ordered, -> { order(:position, :id) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A per-tenant managed lookup list — a named set of entries (e.g.
|
|
3
|
+
# "damage_severity" → minor/major/critical). Admins curate the values without a
|
|
4
|
+
# code deploy, and a Loam::FieldDefinition of type "dictionary" can point a
|
|
5
|
+
# custom field at one. Tenant-scoped and audited like every Loam entity; see
|
|
6
|
+
# Loam::Dictionaries for the read API.
|
|
7
|
+
class Dictionary < Loam::TenantRecord
|
|
8
|
+
self.table_name = "loam_dictionaries"
|
|
9
|
+
|
|
10
|
+
include Loam::Auditable
|
|
11
|
+
|
|
12
|
+
has_many :entries, class_name: "Loam::DictionaryEntry",
|
|
13
|
+
foreign_key: :dictionary_id, dependent: :delete_all
|
|
14
|
+
|
|
15
|
+
validates :key, presence: true, uniqueness: { scope: :tenant_id }
|
|
16
|
+
validates :name, presence: true
|
|
17
|
+
|
|
18
|
+
normalizes :key, with: ->(key) { key.to_s.strip.presence }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# One option in a Loam::Dictionary: a stored `value` (the code that lands in a
|
|
3
|
+
# record) plus display metadata (label, color, icon), an ordering `position`,
|
|
4
|
+
# a `is_default` flag, and an `active` switch (a retired option stops being
|
|
5
|
+
# offered without deleting the historical values already stored). Tenant-scoped
|
|
6
|
+
# and audited.
|
|
7
|
+
class DictionaryEntry < Loam::TenantRecord
|
|
8
|
+
self.table_name = "loam_dictionary_entries"
|
|
9
|
+
|
|
10
|
+
include Loam::Auditable
|
|
11
|
+
|
|
12
|
+
belongs_to :dictionary, class_name: "Loam::Dictionary"
|
|
13
|
+
|
|
14
|
+
validates :value, presence: true, uniqueness: { scope: :dictionary_id }
|
|
15
|
+
validates :label, presence: true
|
|
16
|
+
|
|
17
|
+
scope :active, -> { where(active: true) }
|
|
18
|
+
scope :ordered, -> { order(:position, :id) }
|
|
19
|
+
|
|
20
|
+
after_commit :clear_dictionary_cache
|
|
21
|
+
after_destroy_commit :clear_dictionary_cache
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# A within-request edit must not read a stale cached list (the cache also
|
|
26
|
+
# resets per request, so this only matters when a write and a read share one).
|
|
27
|
+
def clear_dictionary_cache
|
|
28
|
+
Loam::Dictionaries.clear_cache
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# One durable delivery of one event to one persistent subscriber
|
|
3
|
+
# (see Loam::DurableEvents). The ROW is the durable record of intent: it is
|
|
4
|
+
# committed in the event's tenant at publish time, and the background job is
|
|
5
|
+
# only an accelerator. If the job is lost, the sweep re-enqueues from this row.
|
|
6
|
+
#
|
|
7
|
+
# Lifecycle: pending -> delivered (handler ran) | dead (handler removed, or
|
|
8
|
+
# MAX_ATTEMPTS exhausted). A pending row with next_attempt_at in the future is
|
|
9
|
+
# simply waiting out its backoff.
|
|
10
|
+
class EventDelivery < Loam::TenantRecord
|
|
11
|
+
self.table_name = "loam_event_deliveries"
|
|
12
|
+
|
|
13
|
+
STATUSES = %w[pending delivered dead].freeze
|
|
14
|
+
|
|
15
|
+
validates :subscriber_key, :event_name, presence: true
|
|
16
|
+
validates :status, inclusion: { in: STATUSES }
|
|
17
|
+
|
|
18
|
+
scope :pending, -> { where(status: "pending") }
|
|
19
|
+
scope :delivered, -> { where(status: "delivered") }
|
|
20
|
+
scope :dead, -> { where(status: "dead") }
|
|
21
|
+
|
|
22
|
+
# Rows ready for a (re)delivery attempt: pending and past their backoff gate.
|
|
23
|
+
scope :due, ->(now = Time.current) {
|
|
24
|
+
pending.where("next_attempt_at IS NULL OR next_attempt_at <= ?", now)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# The stored payload as a Hash regardless of adapter (Postgres jsonb returns
|
|
28
|
+
# a Hash; a text/json column may hand back a String).
|
|
29
|
+
def payload_hash
|
|
30
|
+
value = self[:payload]
|
|
31
|
+
return value if value.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
JSON.parse(value.to_s.presence || "{}")
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Runtime declaration of a migration-free field on a tenant-scoped entity —
|
|
3
|
+
# "entity_type Equipment gets a field called serial_number of type string,
|
|
4
|
+
# writable by managers only." Tenant-scoped like everything else: a field
|
|
5
|
+
# defined in one tenant is invisible to another's records of the same
|
|
6
|
+
# entity_type, and definitions are the ONLY way values in an entity's
|
|
7
|
+
# `custom_fields` json column get read/written (see Loam::CustomFields).
|
|
8
|
+
class FieldDefinition < Loam::TenantRecord
|
|
9
|
+
self.table_name = "loam_field_definitions"
|
|
10
|
+
|
|
11
|
+
FIELD_TYPES = %w[string text integer decimal boolean date datetime dictionary].freeze
|
|
12
|
+
|
|
13
|
+
validates :entity_type, presence: true
|
|
14
|
+
validates :name, presence: true, uniqueness: { scope: %i[tenant_id entity_type] }
|
|
15
|
+
validates :field_type, presence: true, inclusion: { in: FIELD_TYPES }
|
|
16
|
+
validate :dictionary_key_resolves, if: -> { field_type == "dictionary" }
|
|
17
|
+
|
|
18
|
+
# Field-level READ gate for the runtime field, mirroring writable_roles: an
|
|
19
|
+
# empty readable_roles means any member may read it; otherwise only the listed
|
|
20
|
+
# roles. Used by Loam::CustomFieldIndex (so a filter can't be an oracle on a
|
|
21
|
+
# restricted field) and Loam::Policy#custom_field_readable?.
|
|
22
|
+
def readable_by?(role)
|
|
23
|
+
roles = readable_roles
|
|
24
|
+
roles.blank? || (role.present? && roles.map(&:to_sym).include?(role.to_sym))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The `config` json holds type-specific settings. For a "dictionary" field it
|
|
28
|
+
# carries the key of the Loam::Dictionary whose entries populate the select.
|
|
29
|
+
def dictionary_key
|
|
30
|
+
config.is_a?(Hash) ? config["dictionary"] : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dictionary_key=(value)
|
|
34
|
+
base = config.is_a?(Hash) ? config : {}
|
|
35
|
+
self.config = base.merge("dictionary" => value.to_s.strip.presence)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def dictionary_key_resolves
|
|
41
|
+
if dictionary_key.blank?
|
|
42
|
+
errors.add(:dictionary_key, "is required for a dictionary field")
|
|
43
|
+
elsif Loam::Dictionaries.get(dictionary_key).nil?
|
|
44
|
+
errors.add(:dictionary_key, "must name an existing dictionary in this tenant")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# One received inbound webhook. Two jobs: an idempotency ledger (a unique
|
|
3
|
+
# (source_id, external_id) index turns a replayed delivery into a no-op) and an
|
|
4
|
+
# audit/payload store — the raw body is kept here so an event subscriber reads
|
|
5
|
+
# it, keeping the published event payload scalar-clean (ids only).
|
|
6
|
+
class InboundWebhookDelivery < Loam::TenantRecord
|
|
7
|
+
self.table_name = "loam_inbound_webhook_deliveries"
|
|
8
|
+
|
|
9
|
+
belongs_to :source, class_name: "Loam::InboundWebhookSource", inverse_of: :deliveries
|
|
10
|
+
|
|
11
|
+
validates :external_id, presence: true
|
|
12
|
+
# (source_id, external_id) uniqueness is enforced by a DB index — the ledger
|
|
13
|
+
# must be race-safe, so we rely on the constraint, not a validation.
|
|
14
|
+
|
|
15
|
+
def payload_hash
|
|
16
|
+
value = self[:payload]
|
|
17
|
+
return value if value.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
JSON.parse(value.to_s.presence || "{}")
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A registered external system allowed to POST webhooks INTO this tenant — the
|
|
3
|
+
# inbound sibling of Loam::WebhookEndpoint (which delivers events OUT). The
|
|
4
|
+
# `token` is the unguessable URL id (`/webhooks/:token`) that identifies the
|
|
5
|
+
# source; the `secret` is the HMAC key that AUTHENTICATES each call. Identity is
|
|
6
|
+
# not authority: rotating either is supported from the admin.
|
|
7
|
+
#
|
|
8
|
+
# On a verified delivery, Loam publishes `event_name` onto the domain event bus
|
|
9
|
+
# with a reference to the stored Loam::InboundWebhookDelivery, so durable
|
|
10
|
+
# subscribers (Loam::DurableEvents) react — the payload itself lives on the row.
|
|
11
|
+
class InboundWebhookSource < Loam::TenantRecord
|
|
12
|
+
self.table_name = "loam_inbound_webhook_sources"
|
|
13
|
+
|
|
14
|
+
DEFAULT_SIGNATURE_HEADER = "X-Loam-Signature".freeze
|
|
15
|
+
DEFAULT_TOLERANCE = 300 # seconds
|
|
16
|
+
|
|
17
|
+
has_many :deliveries, class_name: "Loam::InboundWebhookDelivery",
|
|
18
|
+
foreign_key: :source_id, dependent: :destroy, inverse_of: :source
|
|
19
|
+
|
|
20
|
+
validates :name, presence: true
|
|
21
|
+
validates :token, presence: true, uniqueness: true
|
|
22
|
+
validates :secret, presence: true
|
|
23
|
+
validates :event_name, presence: true,
|
|
24
|
+
format: { with: Loam::Events::NAME_FORMAT, message: "must follow domain.thing.happened" }
|
|
25
|
+
|
|
26
|
+
scope :active, -> { where(active: true) }
|
|
27
|
+
|
|
28
|
+
before_validation on: :create do
|
|
29
|
+
self.token ||= SecureRandom.hex(24)
|
|
30
|
+
self.secret ||= SecureRandom.hex(32)
|
|
31
|
+
self.signature_header = DEFAULT_SIGNATURE_HEADER if signature_header.blank?
|
|
32
|
+
self.active = true if active.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# THE blessed cross-tenant lookup (see Loam::ApiToken.authenticate): a public
|
|
36
|
+
# inbound request arrives with no tenant context — the token in the URL is how
|
|
37
|
+
# it discovers its tenant. Establishes Loam::Current.tenant (never an actor:
|
|
38
|
+
# the sender is a machine, not a user) and returns the ACTIVE source, or nil.
|
|
39
|
+
def self.resolve(raw_token)
|
|
40
|
+
return nil if raw_token.blank?
|
|
41
|
+
|
|
42
|
+
source = unscoped.find_by(token: raw_token)
|
|
43
|
+
return nil unless source&.active?
|
|
44
|
+
|
|
45
|
+
Loam::Current.tenant = source.tenant
|
|
46
|
+
source
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def signature_header_key = signature_header.presence || DEFAULT_SIGNATURE_HEADER
|
|
50
|
+
def tolerance = (timestamp_tolerance.presence || DEFAULT_TOLERANCE).to_i
|
|
51
|
+
|
|
52
|
+
def rotate_token! = update!(token: SecureRandom.hex(24))
|
|
53
|
+
def rotate_secret! = update!(secret: SecureRandom.hex(32))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Connects an actor (the host app's User) to a tenant with a role. Roles are
|
|
3
|
+
# plain strings ("manager", "employee", ...) — policies interpret them.
|
|
4
|
+
# Tenant-scoped like everything else: asking for someone's role always means
|
|
5
|
+
# "their role in the CURRENT tenant".
|
|
6
|
+
class Membership < Loam::TenantRecord
|
|
7
|
+
self.table_name = "loam_memberships"
|
|
8
|
+
|
|
9
|
+
belongs_to :user
|
|
10
|
+
|
|
11
|
+
validates :role, presence: true
|
|
12
|
+
validates :user_id, uniqueness: { scope: :tenant_id }
|
|
13
|
+
|
|
14
|
+
# The other blessed cross-tenant lookup (see Loam::ApiToken.authenticate).
|
|
15
|
+
# "Which tenants may this person enter?" is asked at login, before any
|
|
16
|
+
# tenant is chosen, so it cannot be answered from inside one — which is why
|
|
17
|
+
# it lives in the gem rather than in host app code, where reaching across
|
|
18
|
+
# tenants is a guardrail failure.
|
|
19
|
+
#
|
|
20
|
+
# Returns a Loam::Tenant relation, so callers can order/filter it further.
|
|
21
|
+
def self.tenants_for(user)
|
|
22
|
+
user_id = user.respond_to?(:id) ? user.id : user
|
|
23
|
+
|
|
24
|
+
Loam::Tenant.where(id: unscoped.where(user_id: user_id).select(:tenant_id)).order(:name)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require "bcrypt"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
# A user's multi-factor credential: a TOTP secret plus single-use recovery
|
|
5
|
+
# codes. Deliberately NOT tenant-scoped — MFA belongs to the PERSON, who spans
|
|
6
|
+
# tenants, and the second-factor challenge runs at login BEFORE any tenant is
|
|
7
|
+
# chosen. So the secret is encrypted under a USER-scoped key (Loam::Encryptable
|
|
8
|
+
# `scope:`), which decrypts in any tenant and with no tenant at all — the whole
|
|
9
|
+
# reason a per-tenant key would be a lockout bug here.
|
|
10
|
+
class MfaCredential < ApplicationRecord
|
|
11
|
+
include Loam::GeneratedKey
|
|
12
|
+
self.table_name = "loam_mfa_credentials"
|
|
13
|
+
|
|
14
|
+
RECOVERY_CODE_COUNT = 10
|
|
15
|
+
|
|
16
|
+
belongs_to :user
|
|
17
|
+
|
|
18
|
+
include Loam::Encryptable
|
|
19
|
+
encrypts :totp_secret, scope: ->(credential) { "user/#{credential.user_id}" }
|
|
20
|
+
|
|
21
|
+
# Recovery codes are stored HASHED (BCrypt), never in the clear: [{ "digest",
|
|
22
|
+
# "used_at" }]. The plaintext is shown once, at generation, and then only the
|
|
23
|
+
# user has it.
|
|
24
|
+
serialize :recovery_codes, coder: JSON, type: Array
|
|
25
|
+
|
|
26
|
+
validates :user_id, uniqueness: true
|
|
27
|
+
|
|
28
|
+
# The active credential for a user, or nil — nil while enrollment is pending
|
|
29
|
+
# (a secret exists but was never confirmed) so an un-activated credential
|
|
30
|
+
# never gates login.
|
|
31
|
+
def self.active_for(user)
|
|
32
|
+
return nil unless user
|
|
33
|
+
|
|
34
|
+
credential = find_by(user_id: user.id)
|
|
35
|
+
credential&.activated? ? credential : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def activated?
|
|
39
|
+
activated_at.present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Confirm enrollment against a CANDIDATE secret (held in the session, never
|
|
43
|
+
# written until proven) with a live code, then activate: adopt the secret,
|
|
44
|
+
# mint recovery codes, and record the confirming step so it cannot be
|
|
45
|
+
# replayed at the next login. Returns the plaintext codes (shown ONCE) or nil
|
|
46
|
+
# if the code is wrong. The old secret stays valid until this succeeds, so a
|
|
47
|
+
# half-finished re-enrollment never downgrades an active credential.
|
|
48
|
+
def activate_with!(candidate_secret, code)
|
|
49
|
+
step = Loam::Totp.matching_step(candidate_secret, code)
|
|
50
|
+
return nil unless step
|
|
51
|
+
|
|
52
|
+
self.totp_secret = candidate_secret
|
|
53
|
+
self.activated_at = Time.current
|
|
54
|
+
self.last_totp_step = step
|
|
55
|
+
plaintext = mint_recovery_codes!
|
|
56
|
+
save!
|
|
57
|
+
plaintext
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Verify a TOTP code AND consume its timestep, so a captured code cannot be
|
|
61
|
+
# replayed within its ~90s validity window (at login or at sudo). The lock +
|
|
62
|
+
# last_totp_step check closes the read-modify-write race of two concurrent
|
|
63
|
+
# submits. On SQLite `FOR UPDATE` is dropped (writer serialization + the
|
|
64
|
+
# re-check still hold); Postgres takes a real row lock.
|
|
65
|
+
def verify_totp(code)
|
|
66
|
+
return false unless activated?
|
|
67
|
+
|
|
68
|
+
with_lock do
|
|
69
|
+
step = Loam::Totp.matching_step(totp_secret, code)
|
|
70
|
+
if step && (last_totp_step.nil? || step > last_totp_step)
|
|
71
|
+
update!(last_totp_step: step)
|
|
72
|
+
true
|
|
73
|
+
else
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Consume a recovery code: valid exactly once. with_lock reloads and
|
|
80
|
+
# re-checks inside the transaction, so two concurrent submits of the same
|
|
81
|
+
# code cannot both succeed. Constant-time per candidate via BCrypt.
|
|
82
|
+
def consume_recovery_code(code)
|
|
83
|
+
code = code.to_s.strip.downcase
|
|
84
|
+
return false if code.empty?
|
|
85
|
+
|
|
86
|
+
with_lock do
|
|
87
|
+
entry = recovery_codes.find { |e| e["used_at"].nil? && BCrypt::Password.new(e["digest"]) == code }
|
|
88
|
+
if entry
|
|
89
|
+
entry["used_at"] = Time.current.iso8601
|
|
90
|
+
save!
|
|
91
|
+
true
|
|
92
|
+
else
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def unused_recovery_code_count
|
|
99
|
+
recovery_codes.count { |e| e["used_at"].nil? }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def provisioning_uri(issuer:)
|
|
103
|
+
Loam::Totp.provisioning_uri(totp_secret, account: user.email, issuer: issuer)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def mint_recovery_codes!
|
|
109
|
+
plaintext = Array.new(RECOVERY_CODE_COUNT) { SecureRandom.alphanumeric(10).downcase }
|
|
110
|
+
self.recovery_codes = plaintext.map { |code| { "digest" => BCrypt::Password.create(code), "used_at" => nil } }
|
|
111
|
+
plaintext
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# One in-app message for one recipient (`user`) in one tenant. The same
|
|
3
|
+
# person in two tenants has two separate inboxes — like everything else in
|
|
4
|
+
# Loam, "your notifications" always means "in the current tenant".
|
|
5
|
+
#
|
|
6
|
+
# Plumbing, not business data: not audited, the same way Loam::AuditRecord
|
|
7
|
+
# isn't. It IS evented, though — creating one publishes "loam.notification.created"
|
|
8
|
+
# (carrying the recipient's user_id) so Loam::EventStream can push it to that
|
|
9
|
+
# user's browser and the bell updates live. Created through
|
|
10
|
+
# Loam::Notifications.notify, read through the admin bell.
|
|
11
|
+
class Notification < Loam::TenantRecord
|
|
12
|
+
self.table_name = "loam_notifications"
|
|
13
|
+
|
|
14
|
+
belongs_to :user
|
|
15
|
+
|
|
16
|
+
validates :title, presence: true
|
|
17
|
+
|
|
18
|
+
scope :unread, -> { where(read_at: nil) }
|
|
19
|
+
|
|
20
|
+
# The one signal the real-time bell listens for. Only id + recipient ride the
|
|
21
|
+
# event (Loam::Events also stamps tenant_id); no message content is broadcast.
|
|
22
|
+
after_create_commit do
|
|
23
|
+
Loam::Events.publish("loam.notification.created", id: id, user_id: user_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What the notification is about, if anything (source_type/source_id) —
|
|
27
|
+
# stored rather than associated, so a notification survives its subject.
|
|
28
|
+
|
|
29
|
+
def read? = read_at.present?
|
|
30
|
+
|
|
31
|
+
def mark_read!
|
|
32
|
+
update!(read_at: Time.current) unless read?
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A staged mutation awaiting human approval. When a caller runs under
|
|
3
|
+
# confirm-mode (an MCP tool acting for an AI agent — see Loam.mutation_mode), a
|
|
4
|
+
# write is staged HERE as a preview instead of committing; a manager approves
|
|
5
|
+
# or rejects, and only approval executes it. This is Loam's thesis made
|
|
6
|
+
# concrete: agent-triggered writes are gate-able.
|
|
7
|
+
#
|
|
8
|
+
# The approval gate IS a workflow (Loam::Workflow): pending → (manager) approve
|
|
9
|
+
# / reject → executed / failed. "Who may approve" lives in one declarative,
|
|
10
|
+
# role-gated place. The proposed changes are encrypted at rest (Loam::Encryptable),
|
|
11
|
+
# because a staged change to an encrypted target field would otherwise sit here
|
|
12
|
+
# — and in this row's own audit — as plaintext, reopening the leak L-901 closed.
|
|
13
|
+
class PendingAction < Loam::TenantRecord
|
|
14
|
+
self.table_name = "loam_pending_actions"
|
|
15
|
+
|
|
16
|
+
include Loam::Auditable
|
|
17
|
+
include Loam::Encryptable
|
|
18
|
+
include Loam::Workflow
|
|
19
|
+
|
|
20
|
+
belongs_to :actor, class_name: "User", optional: true
|
|
21
|
+
belongs_to :reviewer, class_name: "User", foreign_key: :reviewed_by_id, optional: true
|
|
22
|
+
|
|
23
|
+
# Encrypted JSON. Encryptable also makes Auditable redact `changeset` to
|
|
24
|
+
# "[encrypted]" automatically, so the proposal never lands in the audit trail.
|
|
25
|
+
encrypts :changeset
|
|
26
|
+
|
|
27
|
+
validates :action_type, :summary, :idempotency_key, presence: true
|
|
28
|
+
# Only ONE pending row per proposal per tenant — a rejected/executed row with
|
|
29
|
+
# the same key may coexist so the proposal can be re-staged later. `conditions`
|
|
30
|
+
# scopes the existence check to pending rows (so a rejected row does not block
|
|
31
|
+
# a re-stage); `if: :pending?` skips the check on a non-pending row's own saves.
|
|
32
|
+
# Both mirror the partial DB index (WHERE status = 'pending').
|
|
33
|
+
validates :idempotency_key,
|
|
34
|
+
uniqueness: { scope: :tenant_id, conditions: -> { where(status: "pending") } },
|
|
35
|
+
if: :pending?
|
|
36
|
+
|
|
37
|
+
# The review queue: still awaiting a decision.
|
|
38
|
+
scope :pending, -> { where(status: "pending") }
|
|
39
|
+
|
|
40
|
+
# status is a workflow column, but a plain column underneath — guard it so a
|
|
41
|
+
# direct `update!(status: "executed")` cannot skip the manager role gate and
|
|
42
|
+
# execute_change!. Only a `to_*` transition (which sets the flag below) may
|
|
43
|
+
# move it. `update_column` / raw SQL remain the deliberate low-level escape
|
|
44
|
+
# hatch, like `.unscoped`.
|
|
45
|
+
validate :status_changes_only_through_a_transition, on: :update
|
|
46
|
+
# ...and a new row must START pending — otherwise create!(status: "executed")
|
|
47
|
+
# would forge an already-approved/executed record without any transition.
|
|
48
|
+
validate :status_starts_at_the_initial_state, on: :create
|
|
49
|
+
|
|
50
|
+
# Transitions are prefixed `to_` so their generated bang methods do not
|
|
51
|
+
# collide with the public approve!/reject!(by:) API below.
|
|
52
|
+
workflow :status, initial: "pending" do
|
|
53
|
+
state "pending"
|
|
54
|
+
state "approved"
|
|
55
|
+
state "rejected"
|
|
56
|
+
state "executed"
|
|
57
|
+
state "failed"
|
|
58
|
+
|
|
59
|
+
transition :to_approved, from: "pending", to: "approved", roles: [ :manager ]
|
|
60
|
+
transition :to_rejected, from: "pending", to: "rejected", roles: [ :manager ]
|
|
61
|
+
transition :to_executed, from: "approved", to: "executed"
|
|
62
|
+
transition :to_failed, from: "approved", to: "failed"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `changeset` is a Hash in Ruby but an encrypted JSON string at rest. The
|
|
66
|
+
# class methods win over Encryptable's included module and reach it via super.
|
|
67
|
+
def changeset
|
|
68
|
+
raw = super
|
|
69
|
+
raw.present? ? JSON.parse(raw) : {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def changeset=(value)
|
|
73
|
+
super(value.nil? ? nil : value.to_json)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A structured before/after diff. An encrypted target field shows
|
|
77
|
+
# "[encrypted]" on BOTH sides — a reviewer sees THAT a secret changes, never
|
|
78
|
+
# its value.
|
|
79
|
+
def preview
|
|
80
|
+
target = load_target
|
|
81
|
+
encrypted = encrypted_target_fields
|
|
82
|
+
|
|
83
|
+
changeset.each_with_object({}) do |(field, proposed), diff|
|
|
84
|
+
field = field.to_s
|
|
85
|
+
diff[field] =
|
|
86
|
+
if encrypted.include?(field)
|
|
87
|
+
{ "from" => (target ? "[encrypted]" : nil), "to" => "[encrypted]" }
|
|
88
|
+
else
|
|
89
|
+
{ "from" => target&.read_attribute(field), "to" => proposed }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Approve and execute, atomically enough: the transition is role-gated to a
|
|
95
|
+
# manager; execution runs in a transaction so a failure leaves NO partial
|
|
96
|
+
# write; the record ends "executed" or "failed". Executes as `by`, so the
|
|
97
|
+
# TARGET's audit names the approving human — the person owns the change.
|
|
98
|
+
def approve!(by:)
|
|
99
|
+
Loam.as_tenant(tenant, actor: by) do
|
|
100
|
+
reject_self_approval!(by)
|
|
101
|
+
self.reviewed_by_id = by.id
|
|
102
|
+
self.reviewed_at = Time.current
|
|
103
|
+
to_approved! # NotAuthorizedError if not a manager; InvalidTransitionError if not pending
|
|
104
|
+
execute_and_record!
|
|
105
|
+
end
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reject!(by:, reason: nil)
|
|
110
|
+
Loam.as_tenant(tenant, actor: by) do
|
|
111
|
+
self.reviewed_by_id = by.id
|
|
112
|
+
self.reviewed_at = Time.current
|
|
113
|
+
self.result = [ "rejected", reason.presence ].compact.join(": ")
|
|
114
|
+
to_rejected!
|
|
115
|
+
end
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Segregation of duties: the person who staged a change must not be the one
|
|
122
|
+
# who approves it — normally the proposer is an AI agent and the approver a
|
|
123
|
+
# human. Opt out per tenant with the "approvals.allow_self_approve" flag. A
|
|
124
|
+
# proposal with no recorded actor (actor_id nil) bypasses the check.
|
|
125
|
+
# (reject! is deliberately NOT gated: rejecting your own proposal is a
|
|
126
|
+
# withdrawal, not a segregation-of-duties concern.)
|
|
127
|
+
def reject_self_approval!(by)
|
|
128
|
+
return unless by.id == actor_id
|
|
129
|
+
return if Loam::Configs.get("approvals.allow_self_approve", default: false)
|
|
130
|
+
|
|
131
|
+
raise Loam::NotAuthorizedError, "self-approval is not permitted; a different person must approve this change"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Execution AND its status/result recording share ONE transaction with the
|
|
135
|
+
# target write: if recording the outcome fails after the target committed,
|
|
136
|
+
# the whole thing rolls back rather than leaving the change applied but the
|
|
137
|
+
# status stuck at "approved".
|
|
138
|
+
def execute_and_record!
|
|
139
|
+
ActiveRecord::Base.transaction do
|
|
140
|
+
outcome = execute_change!
|
|
141
|
+
to_executed!
|
|
142
|
+
update!(result: outcome)
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError => error
|
|
145
|
+
# The transaction rolled back, but Active Record leaves the in-memory
|
|
146
|
+
# attributes as they were mid-transaction — reload to the real DB state
|
|
147
|
+
# ("approved") before transitioning to "failed".
|
|
148
|
+
reload
|
|
149
|
+
to_failed!
|
|
150
|
+
update!(error: error.message)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def execute_change!
|
|
154
|
+
case action_type
|
|
155
|
+
when "create"
|
|
156
|
+
record = target_class.create!(changeset)
|
|
157
|
+
"created #{target_type}##{record.id}"
|
|
158
|
+
when "update"
|
|
159
|
+
target_class.find(target_id).update!(changeset)
|
|
160
|
+
"updated #{target_type}##{target_id}"
|
|
161
|
+
when "destroy"
|
|
162
|
+
target = target_class.find(target_id)
|
|
163
|
+
target.respond_to?(:soft_delete!) ? target.soft_delete! : target.destroy!
|
|
164
|
+
"deleted #{target_type}##{target_id}"
|
|
165
|
+
else
|
|
166
|
+
raise Loam::Error, "unknown action_type #{action_type.inspect}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def target_class
|
|
171
|
+
target_type.constantize
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def load_target
|
|
175
|
+
return nil if target_id.nil?
|
|
176
|
+
|
|
177
|
+
target_class.find_by(id: target_id)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def encrypted_target_fields
|
|
181
|
+
return [] unless target_class.respond_to?(:loam_encrypted_attributes)
|
|
182
|
+
|
|
183
|
+
target_class.loam_encrypted_attributes.map(&:to_s)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Set while a workflow transition is performing its save, so the validation
|
|
187
|
+
# below can tell a legitimate status move from a direct assignment.
|
|
188
|
+
def loam_perform_transition!(transition)
|
|
189
|
+
@loam_status_via_transition = true
|
|
190
|
+
super
|
|
191
|
+
ensure
|
|
192
|
+
@loam_status_via_transition = false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def status_changes_only_through_a_transition
|
|
196
|
+
return unless will_save_change_to_status?
|
|
197
|
+
return if @loam_status_via_transition
|
|
198
|
+
|
|
199
|
+
errors.add(:status, "may only change through an approval transition (approve!/reject!), not a direct write")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def status_starts_at_the_initial_state
|
|
203
|
+
return if status == self.class.loam_workflow.initial
|
|
204
|
+
|
|
205
|
+
errors.add(:status, "must start at #{self.class.loam_workflow.initial.inspect} — a staged action begins pending")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|