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,80 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The real-time bridge: pushes selected Loam events to a browser over
|
|
3
|
+
# Server-Sent Events, so the admin updates live instead of polling.
|
|
4
|
+
#
|
|
5
|
+
# SECURITY POSTURE — default OFF. An event reaches a browser only if ALL hold:
|
|
6
|
+
# * its name matches a declared Loam.broadcast_events pattern (opt-in), AND
|
|
7
|
+
# * its tenant matches the connected tenant (isolation), AND
|
|
8
|
+
# * its audience includes the connected actor (a payload `user_id`, if any,
|
|
9
|
+
# is the sole recipient; no `user_id` means tenant-wide).
|
|
10
|
+
#
|
|
11
|
+
# FAN-OUT is behind a swappable broadcaster seam (Loam::EventStream.broadcaster).
|
|
12
|
+
# The default in-process broadcaster only sees events published in THIS process
|
|
13
|
+
# — fine for the single-process prototype; a multi-process deploy swaps in a
|
|
14
|
+
# Redis/SolidCable-backed broadcaster with no controller change (see
|
|
15
|
+
# docs/_foundation/overview.md).
|
|
16
|
+
module EventStream
|
|
17
|
+
class << self
|
|
18
|
+
attr_writer :broadcaster
|
|
19
|
+
|
|
20
|
+
def broadcaster
|
|
21
|
+
@broadcaster ||= InProcessBroadcaster.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Is this event name allowed to reach browsers at all? Empty allow-list →
|
|
25
|
+
# false, always (nothing leaks by default).
|
|
26
|
+
def broadcastable?(event_name)
|
|
27
|
+
Loam.broadcast_events.any? do |pattern|
|
|
28
|
+
next false if Loam::Overrides.disabled?(:broadcast_events, pattern) # an app can turn a default pattern off
|
|
29
|
+
|
|
30
|
+
Loam::Events.pattern_matches?(pattern, event_name)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Should a broadcastable event reach a stream connected as (tenant, actor)?
|
|
35
|
+
def deliverable?(event_name, payload, tenant:, actor:)
|
|
36
|
+
return false unless broadcastable?(event_name)
|
|
37
|
+
|
|
38
|
+
payload = payload.symbolize_keys
|
|
39
|
+
return false unless payload[:tenant_id] == tenant&.id
|
|
40
|
+
|
|
41
|
+
recipient = payload[:user_id]
|
|
42
|
+
recipient.nil? || recipient == actor&.id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One SSE message: an `event:` line (the Loam event name) and a `data:`
|
|
46
|
+
# line (JSON), ended by a blank line. Only small id-ish keys ride along —
|
|
47
|
+
# Loam events carry no attribute values, and this slices to a safe set as
|
|
48
|
+
# belt-and-suspenders (tenant_id is dropped; it is implied by the connection).
|
|
49
|
+
def frame(event_name, payload)
|
|
50
|
+
"event: #{event_name}\ndata: #{safe_payload(payload).to_json}\n\n"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def safe_payload(payload)
|
|
56
|
+
# id-ish keys only — never attribute values. percent/status ride along for
|
|
57
|
+
# the progress bar (Loam::ProgressJob); both are non-sensitive.
|
|
58
|
+
payload.symbolize_keys.slice(:id, :type, :user_id, :from, :to, :percent, :status).compact
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The default fan-out: subscribe to Loam::Events in THIS process and forward
|
|
63
|
+
# the events deliverable to (tenant, actor) to a sink. A `sink` is anything
|
|
64
|
+
# answering #call(sse_string) — the controller's is a Queue push.
|
|
65
|
+
class InProcessBroadcaster
|
|
66
|
+
# Returns an opaque handle to pass back to #unsubscribe.
|
|
67
|
+
def subscribe(tenant:, actor:, &sink)
|
|
68
|
+
Loam::Events.subscribe_all do |event_name, payload|
|
|
69
|
+
next unless Loam::EventStream.deliverable?(event_name, payload, tenant: tenant, actor: actor)
|
|
70
|
+
|
|
71
|
+
sink.call(Loam::EventStream.frame(event_name, payload))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unsubscribe(handle)
|
|
76
|
+
ActiveSupport::Notifications.unsubscribe(handle) if handle
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Lifecycle events by convention. Included in every generated entity:
|
|
3
|
+
#
|
|
4
|
+
# class Equipment < Loam::TenantRecord
|
|
5
|
+
# include Loam::Eventful
|
|
6
|
+
# event_domain :rental # -> "rental.equipment.created" etc.
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# Custom business events are published explicitly with Loam::Events.publish.
|
|
10
|
+
module Eventful
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
class_attribute :loam_event_domain, default: "app"
|
|
15
|
+
class_attribute :loam_event_entity, default: nil
|
|
16
|
+
|
|
17
|
+
after_create_commit { publish_lifecycle_event("created") }
|
|
18
|
+
after_update_commit { publish_lifecycle_event("updated") }
|
|
19
|
+
after_destroy_commit { publish_lifecycle_event("destroyed") }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class_methods do
|
|
23
|
+
def event_domain(domain)
|
|
24
|
+
self.loam_event_domain = domain.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The middle segment of the event name. It defaults to the model's param
|
|
28
|
+
# key, which is right for app models but awkward for namespaced ones —
|
|
29
|
+
# Loam::Comment would publish "loam.loam_comment.created" rather than
|
|
30
|
+
# "loam.comment.created".
|
|
31
|
+
def event_entity(name)
|
|
32
|
+
self.loam_event_entity = name.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def publish_lifecycle_event(happened)
|
|
39
|
+
Loam::Events.publish(
|
|
40
|
+
"#{loam_event_domain}.#{loam_event_entity || model_name.param_key}.#{happened}",
|
|
41
|
+
{ id: id, type: self.class.name }
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/loam/events.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# A thin domain event bus over ActiveSupport::Notifications.
|
|
3
|
+
#
|
|
4
|
+
# Convention: event names are `domain.thing.happened`, e.g.
|
|
5
|
+
# "billing.subscription.renewed", "rental.equipment.created".
|
|
6
|
+
# Publishing stamps the current tenant and actor onto the payload so
|
|
7
|
+
# subscribers are always tenant-aware.
|
|
8
|
+
module Events
|
|
9
|
+
NAME_FORMAT = /\A[a-z0-9_]+(\.[a-z0-9_]+){2,}\z/
|
|
10
|
+
PREFIX = "loam.event."
|
|
11
|
+
|
|
12
|
+
def self.publish(name, payload = {})
|
|
13
|
+
name = name.to_s
|
|
14
|
+
unless name.match?(NAME_FORMAT)
|
|
15
|
+
raise InvalidEventNameError, "Event name #{name.inspect} must follow `domain.thing.happened`"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ActiveSupport::Notifications.instrument(
|
|
19
|
+
PREFIX + name,
|
|
20
|
+
payload.merge(tenant_id: Loam::Current.tenant&.id, actor_id: Loam::Current.actor&.id)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The subscription rule, in one place: a trailing dot is a domain prefix,
|
|
25
|
+
# anything else is an exact event name. Loam::WebhookEndpoint matches
|
|
26
|
+
# against this too, so a pattern means the same thing everywhere.
|
|
27
|
+
def self.pattern_matches?(pattern, event_name)
|
|
28
|
+
pattern = pattern.to_s
|
|
29
|
+
event_name = event_name.to_s
|
|
30
|
+
|
|
31
|
+
pattern.end_with?(".") ? event_name.start_with?(pattern) : event_name == pattern
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Every Loam event, whatever its domain — the empty prefix matches them
|
|
35
|
+
# all. Used by the webhook dispatcher, which decides per event which
|
|
36
|
+
# endpoints care.
|
|
37
|
+
def self.subscribe_all(&block)
|
|
38
|
+
subscribe("", &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Subscribe to one event ("rental.equipment.created") or a whole domain
|
|
42
|
+
# ("rental.") — the block receives (event_name, payload).
|
|
43
|
+
def self.subscribe(name_or_prefix, &block)
|
|
44
|
+
pattern = PREFIX + name_or_prefix.to_s
|
|
45
|
+
matcher = pattern.end_with?(".") ? /\A#{Regexp.escape(pattern)}/ : pattern
|
|
46
|
+
ActiveSupport::Notifications.subscribe(matcher) do |full_name, _start, _finish, _id, payload|
|
|
47
|
+
block.call(full_name.delete_prefix(PREFIX), payload)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/loam/export.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
# CSV export of a tenant-scoped relation, POLICY- and ENCRYPTION-aware:
|
|
5
|
+
#
|
|
6
|
+
# Loam::Export.csv(Equipment.all, actor: current_actor)
|
|
7
|
+
#
|
|
8
|
+
# * only fields the actor's role may READ are columns (Loam::Policy#readable?);
|
|
9
|
+
# * an ENCRYPTED column is NEVER exported in the clear — its cell is
|
|
10
|
+
# "[encrypted]" (the same redaction as the audit trail), so a bulk export can
|
|
11
|
+
# never become a plaintext dump of PII a role shouldn't see;
|
|
12
|
+
# * declared custom fields are included (a dictionary field exports its stored
|
|
13
|
+
# code, so the file round-trips back through Loam::Import);
|
|
14
|
+
# * tenant isolation is free — the relation is already scoped.
|
|
15
|
+
#
|
|
16
|
+
# Prototype scale: builds the CSV in memory with the stdlib CSV. A very large
|
|
17
|
+
# export would stream row-by-row through an enumerator body — the same column
|
|
18
|
+
# logic, a different sink.
|
|
19
|
+
module Export
|
|
20
|
+
REDACTED = "[encrypted]".freeze
|
|
21
|
+
# Never exported: tenant plumbing and optimistic-locking bookkeeping.
|
|
22
|
+
SKIP_COLUMNS = %w[tenant_id lock_version deleted_at].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def csv(scope, actor:)
|
|
27
|
+
model = scope.klass
|
|
28
|
+
columns = exportable_columns(model, actor)
|
|
29
|
+
|
|
30
|
+
CSV.generate do |out|
|
|
31
|
+
out << columns.map { |c| c[:header] }
|
|
32
|
+
scope.find_each { |record| out << columns.map { |c| cell(record, c) } }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The ordered column spec: readable real columns (encrypted ones kept but
|
|
37
|
+
# redacted), then declared custom fields.
|
|
38
|
+
def exportable_columns(model, actor)
|
|
39
|
+
policy = policy_for(model, actor)
|
|
40
|
+
encrypted = model.respond_to?(:loam_encrypted_attributes) ? model.loam_encrypted_attributes : []
|
|
41
|
+
# The blind-index columns behind searchable encrypted fields (e.g.
|
|
42
|
+
# email_hash) are internal HMACs — never export them either.
|
|
43
|
+
blind = model.respond_to?(:loam_searchable_encrypted_attributes) ? model.loam_searchable_encrypted_attributes.map { |a| "#{a}_hash" } : []
|
|
44
|
+
|
|
45
|
+
columns = model.column_names.reject { |c| SKIP_COLUMNS.include?(c) || blind.include?(c) || c == "custom_fields" }
|
|
46
|
+
.select { |c| policy.readable?(c) }
|
|
47
|
+
.map { |c| { header: c, name: c, kind: encrypted.include?(c) ? :encrypted : :column } }
|
|
48
|
+
|
|
49
|
+
if model.respond_to?(:custom_field_definitions)
|
|
50
|
+
model.custom_field_definitions.order(:name).each do |definition|
|
|
51
|
+
columns << { header: definition.name, name: definition.name, kind: :custom }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
columns
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cell(record, column)
|
|
59
|
+
value = case column[:kind]
|
|
60
|
+
when :encrypted then REDACTED
|
|
61
|
+
when :custom then (record.custom_field(column[:name]) rescue nil)
|
|
62
|
+
else record.public_send(column[:name])
|
|
63
|
+
end
|
|
64
|
+
Loam::Csv.safe(value) # neutralize CSV formula injection (=, +, -, @, tab/CR)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def policy_for(model, actor)
|
|
68
|
+
klass = "#{model.name}Policy".safe_constantize
|
|
69
|
+
# A blank instance as the record: readable? keys off the role, but the
|
|
70
|
+
# policy's custom-field checks read record.class.
|
|
71
|
+
(klass || Loam::Policy).new(actor, model.new)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Runtime feature toggles: is a capability turned ON for this tenant right now.
|
|
3
|
+
#
|
|
4
|
+
# Loam::Features.on?(:beta_dashboard) # for the current tenant
|
|
5
|
+
# Loam::Features.enable(:beta_dashboard) # this tenant only
|
|
6
|
+
# Loam::Features.enable(:beta_dashboard, scope: :global) # everyone
|
|
7
|
+
# Loam::Features.disable(:beta_dashboard)
|
|
8
|
+
# Loam::Features.reset(:beta_dashboard) # drop the override → default
|
|
9
|
+
#
|
|
10
|
+
# A flag gates a CAPABILITY (is this feature live for the tenant), which is
|
|
11
|
+
# orthogonal to a policy — that gates a PERSON (may this user act). The two
|
|
12
|
+
# coexist: a manager may be allowed to approve reports AND the approvals
|
|
13
|
+
# feature may be switched off for their tenant during a rollout.
|
|
14
|
+
#
|
|
15
|
+
# This is deliberately a thin wrapper over Loam::Configs: a flag is a boolean
|
|
16
|
+
# setting with the same override → global → declared-default resolution, so it
|
|
17
|
+
# reuses that store (no new table) and its per-request cache. Flags live under
|
|
18
|
+
# the reserved `features.` key prefix and get their own admin screen, because
|
|
19
|
+
# "flags" and "settings" are different mental models even sharing storage.
|
|
20
|
+
module Features
|
|
21
|
+
PREFIX = "features.".freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def on?(name)
|
|
25
|
+
!!Loam::Configs.get(key_for(name), default: default_for(name))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def off?(name)
|
|
29
|
+
!on?(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Flip a flag on. scope: :tenant (default) overrides for the current tenant
|
|
33
|
+
# and requires a tenant in context; scope: :global sets the app-wide state.
|
|
34
|
+
def enable(name, scope: :tenant)
|
|
35
|
+
Loam::Configs.set(key_for(name), true, scope: scope)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def disable(name, scope: :tenant)
|
|
39
|
+
Loam::Configs.set(key_for(name), false, scope: scope)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Drop the current tenant's override so the flag falls back to the global
|
|
43
|
+
# state / declared default.
|
|
44
|
+
def reset(name)
|
|
45
|
+
Loam::Configs.reset(key_for(name))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def overridden?(name)
|
|
49
|
+
Loam::Configs.overridden?(key_for(name))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Every declared flag, sorted — the admin lists these whether or not a row
|
|
53
|
+
# exists yet, so a flag is visible the moment it is declared.
|
|
54
|
+
def declared
|
|
55
|
+
Loam.feature_defaults.keys.sort
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def description(name)
|
|
59
|
+
Loam.feature_defaults.dig(name.to_s, :description)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The declared-default state of a flag (false for an unknown one), used as
|
|
63
|
+
# the last resort when there is no override and no global row.
|
|
64
|
+
def default_for(name)
|
|
65
|
+
!!Loam.feature_defaults.dig(name.to_s, :default)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def key_for(name)
|
|
71
|
+
"#{PREFIX}#{name}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Primary-key generation for tables whose key is not an integer.
|
|
3
|
+
#
|
|
4
|
+
# Rails leaves key generation to the database, which works for integer keys
|
|
5
|
+
# (a sequence or AUTOINCREMENT) and for uuid columns on Postgres, where
|
|
6
|
+
# create_table installs a gen_random_uuid() default. A *string* primary key
|
|
7
|
+
# has neither: the INSERT sends NULL and the row is rejected or lands with an
|
|
8
|
+
# empty key.
|
|
9
|
+
#
|
|
10
|
+
# Loam's tables follow the host app's key type (see
|
|
11
|
+
# Loam::Generators::PrimaryKeyOptions), so on a string-keyed app every Loam
|
|
12
|
+
# model needs its key generated in Ruby. Before this, hosts patched it in
|
|
13
|
+
# themselves with a global before_create on ActiveRecord::Base — a lot to ask
|
|
14
|
+
# of an app whose only deviation was not using bigints, and a hook broad
|
|
15
|
+
# enough to reach models that had nothing to do with Loam.
|
|
16
|
+
#
|
|
17
|
+
# Integer keys take the early return, so nothing changes for the default app.
|
|
18
|
+
module GeneratedKey
|
|
19
|
+
def self.included(base)
|
|
20
|
+
base.before_create :assign_loam_generated_key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def assign_loam_generated_key
|
|
26
|
+
key = self.class.primary_key
|
|
27
|
+
return if key.nil? # a join table declared with id: false
|
|
28
|
+
return if self[key].present? # the caller chose the key
|
|
29
|
+
|
|
30
|
+
column = self.class.columns_hash[key]
|
|
31
|
+
return if column.nil?
|
|
32
|
+
return if column.type == :integer # the database generates this one
|
|
33
|
+
|
|
34
|
+
self[key] = SecureRandom.uuid
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/loam/import.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module Loam
|
|
5
|
+
# CSV import mapping engine — reusable, tenant-scoped, policy-safe.
|
|
6
|
+
#
|
|
7
|
+
# Loam::Import.preview(csv) # headers + first rows for the mapping UI
|
|
8
|
+
# Loam::Import.run(csv, model:, mapping:, actor:, # map CSV header => field
|
|
9
|
+
# match_key: "name", # update-or-create by a key (nil = create-only)
|
|
10
|
+
# dry_run: true, # validate + report, commit nothing
|
|
11
|
+
# progress: progress_job) # advance a Loam::ProgressJob per row
|
|
12
|
+
#
|
|
13
|
+
# Safety: the mapping may only target fields the actor's role can WRITE (real
|
|
14
|
+
# columns + declared custom fields); tenant_id, plumbing, and non-permitted
|
|
15
|
+
# fields are refused (no crafted mapping can smuggle a value past the policy).
|
|
16
|
+
# Each row is its OWN save — a bad row is logged (row number + reason) and
|
|
17
|
+
# SKIPPED, never half-committed, and the import continues. Records land in the
|
|
18
|
+
# current tenant automatically (TenantRecord). Malformed CSV is a clean error,
|
|
19
|
+
# not a crash.
|
|
20
|
+
module Import
|
|
21
|
+
PLUMBING = %w[id tenant_id created_at updated_at lock_version deleted_at custom_fields].freeze
|
|
22
|
+
|
|
23
|
+
Result = Struct.new(:created, :updated, :failed, :errors, keyword_init: true) do
|
|
24
|
+
def total = created + updated + failed
|
|
25
|
+
def to_h = { created: created, updated: updated, failed: failed, errors: errors }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Resolve an entity_type string to a model — ONLY a Loam::TenantRecord
|
|
31
|
+
# subclass (never an arbitrary constant), so an import target can't be
|
|
32
|
+
# smuggled to a global model like User (same guard as the business-rules
|
|
33
|
+
# engine).
|
|
34
|
+
def allowed_model(entity_type)
|
|
35
|
+
klass = entity_type.to_s.safe_constantize
|
|
36
|
+
unless klass.is_a?(Class) && klass < Loam::TenantRecord
|
|
37
|
+
raise Loam::Error, "import target #{entity_type.inspect} is not a Loam entity"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def preview(csv_string, limit: 5)
|
|
44
|
+
rows = safe_parse(csv_string)
|
|
45
|
+
{ headers: rows.first || [], rows: rows.drop(1).first(limit) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The fields a mapping may target for `model` given `actor`'s role.
|
|
49
|
+
def allowed_targets(model, actor)
|
|
50
|
+
policy = policy_for(model, actor)
|
|
51
|
+
columns = (model.column_names - PLUMBING).select { |c| policy.writable?(c) }
|
|
52
|
+
customs = if model.respond_to?(:custom_field_definitions)
|
|
53
|
+
model.custom_field_definitions.map(&:name).select { |n| policy.custom_field_writable?(n) }
|
|
54
|
+
else
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
(columns + customs).to_set
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run(csv_string, model:, mapping:, actor:, match_key: nil, dry_run: false, progress: nil)
|
|
61
|
+
refuse_bad_mapping!(mapping, allowed_targets(model, actor))
|
|
62
|
+
|
|
63
|
+
rows = safe_parse(csv_string)
|
|
64
|
+
headers = rows.first || []
|
|
65
|
+
result = Result.new(created: 0, updated: 0, failed: 0, errors: [])
|
|
66
|
+
|
|
67
|
+
rows.drop(1).each_with_index do |row, index|
|
|
68
|
+
line = index + 2 # human row number (1-based + header)
|
|
69
|
+
begin
|
|
70
|
+
attrs = row_attributes(headers, row, mapping)
|
|
71
|
+
record, is_new = find_or_build(model, attrs, match_key)
|
|
72
|
+
assign(record, attrs, model)
|
|
73
|
+
|
|
74
|
+
if dry_run
|
|
75
|
+
raise ActiveRecord::RecordInvalid, record unless record.valid?
|
|
76
|
+
else
|
|
77
|
+
record.save!
|
|
78
|
+
end
|
|
79
|
+
is_new ? (result.created += 1) : (result.updated += 1)
|
|
80
|
+
rescue StandardError => error
|
|
81
|
+
# Store ONLY the row number + message — NEVER the raw cell values. The
|
|
82
|
+
# result is persisted (Loam::ProgressJob.result); a failed row into an
|
|
83
|
+
# encrypted field would otherwise write PLAINTEXT PII at rest.
|
|
84
|
+
result.failed += 1
|
|
85
|
+
result.errors << { "row" => line, "message" => error.message }
|
|
86
|
+
end
|
|
87
|
+
progress&.advance
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The failed rows as a fix-and-re-upload CSV — rebuilt from the ORIGINAL csv
|
|
94
|
+
# (a transient download the user already holds), NOT from the persisted
|
|
95
|
+
# result (which carries no cell values). Each cell is neutralized against CSV
|
|
96
|
+
# formula injection.
|
|
97
|
+
def error_csv(result, csv_string)
|
|
98
|
+
rows = safe_parse(csv_string)
|
|
99
|
+
headers = rows.first || []
|
|
100
|
+
data_rows = rows.drop(1)
|
|
101
|
+
|
|
102
|
+
CSV.generate do |out|
|
|
103
|
+
out << (Array(headers) + [ "_error" ])
|
|
104
|
+
result.errors.each do |error|
|
|
105
|
+
original = data_rows[error["row"] - 2] || [] # row is 1-based incl. the header
|
|
106
|
+
out << original.map { |cell| Loam::Csv.safe(cell) } + [ error["message"] ]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ---- internals ----
|
|
112
|
+
|
|
113
|
+
def refuse_bad_mapping!(mapping, allowed)
|
|
114
|
+
mapping.each_value do |target|
|
|
115
|
+
next if target.to_s.blank?
|
|
116
|
+
next if allowed.include?(target.to_s)
|
|
117
|
+
|
|
118
|
+
raise Loam::Error, "import mapping refuses #{target.inspect} — not a writable column or custom field"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def row_attributes(headers, row, mapping)
|
|
123
|
+
cells = Hash[headers.zip(row)]
|
|
124
|
+
mapping.each_with_object({}) do |(header, target), attrs|
|
|
125
|
+
next if target.to_s.blank?
|
|
126
|
+
|
|
127
|
+
attrs[target.to_s] = cells[header]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find_or_build(model, attrs, match_key)
|
|
132
|
+
key = match_key.to_s
|
|
133
|
+
if key.present? && attrs[key].present?
|
|
134
|
+
# find_by is tenant-scoped, so update-by-key can only ever hit a record
|
|
135
|
+
# in the CURRENT tenant.
|
|
136
|
+
existing = model.find_by(key => attrs[key])
|
|
137
|
+
return [ existing, false ] if existing
|
|
138
|
+
end
|
|
139
|
+
[ model.new, true ]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def assign(record, attrs, model)
|
|
143
|
+
columns = model.column_names.to_set
|
|
144
|
+
customs = model.respond_to?(:custom_field_definitions) ? model.custom_field_definitions.map(&:name).to_set : Set.new
|
|
145
|
+
|
|
146
|
+
attrs.each do |field, value|
|
|
147
|
+
if columns.include?(field)
|
|
148
|
+
record.public_send("#{field}=", value)
|
|
149
|
+
elsif customs.include?(field)
|
|
150
|
+
record.set_custom_field(field, value)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def safe_parse(csv_string)
|
|
156
|
+
CSV.parse(csv_string.to_s)
|
|
157
|
+
rescue CSV::MalformedCSVError => error
|
|
158
|
+
raise Loam::Error, "could not parse CSV: #{error.message}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def policy_for(model, actor)
|
|
162
|
+
klass = "#{model.name}Policy".safe_constantize
|
|
163
|
+
# A blank instance as the record: readable?/writable? key off the role, but
|
|
164
|
+
# custom_field_writable? reads record.class.custom_field_definitions.
|
|
165
|
+
(klass || Loam::Policy).new(actor, model.new)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Loam
|
|
5
|
+
# Receiving webhooks FROM external systems (the inbound sibling of
|
|
6
|
+
# Loam::Webhooks). One entry point — `ingest` — does the whole verified,
|
|
7
|
+
# replay-resistant pipeline and returns a Result the controller turns into an
|
|
8
|
+
# HTTP status. Kept here (not in a controller) so it is testable without HTTP
|
|
9
|
+
# and shared by the generated app and the demo.
|
|
10
|
+
#
|
|
11
|
+
# THE ORDER OF CHECKS is deliberate — cheapest and least-trusting first:
|
|
12
|
+
# 1. body size -> 413 (never HMAC a huge body)
|
|
13
|
+
# 2. token resolve -> 404 (unknown/inactive source)
|
|
14
|
+
# 3. signature -> 401 (constant-time HMAC over the RAW body)
|
|
15
|
+
# 4. timestamp -> 401 (defense-in-depth; see note below)
|
|
16
|
+
# 5. dedupe -> 200 (a replay is idempotent, not an error)
|
|
17
|
+
# 6. ingest+publish -> 202
|
|
18
|
+
#
|
|
19
|
+
# Every AUTH failure returns 401 with no distinguishing body, so a sender can't
|
|
20
|
+
# probe which check failed; the specific reason is logged server-side only.
|
|
21
|
+
#
|
|
22
|
+
# REPLAY: the real defense is the (source_id, external_id) dedupe. The timestamp
|
|
23
|
+
# window is defense-in-depth: unless the sender signs the timestamp too, a
|
|
24
|
+
# replayer can refresh an unsigned timestamp header. Don't over-trust it.
|
|
25
|
+
module InboundWebhooks
|
|
26
|
+
MAX_BYTES = 1_000_000
|
|
27
|
+
|
|
28
|
+
Result = Struct.new(:status, :reason, :delivery, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def ingest(token:, raw_body:, headers:)
|
|
33
|
+
Loam::Telemetry.span("inbound_webhook") { run_ingest(token, raw_body.to_s, headers) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_ingest(token, raw_body, headers)
|
|
37
|
+
return Result.new(status: 413, reason: "body too large") if raw_body.bytesize > MAX_BYTES
|
|
38
|
+
|
|
39
|
+
source = Loam::InboundWebhookSource.resolve(token)
|
|
40
|
+
return Result.new(status: 404, reason: "unknown or inactive source") if source.nil?
|
|
41
|
+
|
|
42
|
+
signature = header(headers, source.signature_header_key)
|
|
43
|
+
return unauthorized("missing signature") if signature.blank?
|
|
44
|
+
return unauthorized("bad signature") unless valid_signature?(source.secret, raw_body, signature)
|
|
45
|
+
|
|
46
|
+
if source.timestamp_header.present?
|
|
47
|
+
return unauthorized("stale or missing timestamp") unless fresh_timestamp?(header(headers, source.timestamp_header), source.tolerance)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
external_id = delivery_id(source, headers, raw_body)
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
delivery = nil
|
|
54
|
+
Loam::InboundWebhookDelivery.transaction do
|
|
55
|
+
delivery = Loam::InboundWebhookDelivery.create!(
|
|
56
|
+
source: source, external_id: external_id, event_name: source.event_name,
|
|
57
|
+
status: "received", received_at: Time.current, payload: parse(raw_body)
|
|
58
|
+
)
|
|
59
|
+
# Scalar-only payload by convention (like the outbound path): the body
|
|
60
|
+
# lives on the delivery row, subscribers read it from there. Publishing
|
|
61
|
+
# inside the txn ties capture to the row — a publish failure rolls the
|
|
62
|
+
# row back so the sender's retry isn't deduped away.
|
|
63
|
+
Loam::Events.publish(source.event_name, { source_id: source.id, delivery_id: delivery.id })
|
|
64
|
+
end
|
|
65
|
+
Result.new(status: 202, reason: "accepted", delivery: delivery)
|
|
66
|
+
rescue ActiveRecord::RecordNotUnique
|
|
67
|
+
# A concurrent or replayed delivery with the same external_id — already
|
|
68
|
+
# processed. Idempotent success, NOT a second publish.
|
|
69
|
+
Result.new(status: 200, reason: "duplicate (already processed)")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- verification internals ---
|
|
74
|
+
|
|
75
|
+
def valid_signature?(secret, body, provided)
|
|
76
|
+
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body)
|
|
77
|
+
# Hash both to a fixed 64-hex length so the compare is constant-time and
|
|
78
|
+
# never raises on an attacker-chosen length.
|
|
79
|
+
ActiveSupport::SecurityUtils.fixed_length_secure_compare(
|
|
80
|
+
Digest::SHA256.hexdigest(expected), Digest::SHA256.hexdigest(provided.to_s)
|
|
81
|
+
)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def fresh_timestamp?(raw, tolerance)
|
|
87
|
+
return false if raw.blank?
|
|
88
|
+
|
|
89
|
+
seconds = (Integer(raw.to_s) rescue (Time.parse(raw.to_s).to_i rescue nil))
|
|
90
|
+
return false if seconds.nil?
|
|
91
|
+
|
|
92
|
+
(Time.current.to_i - seconds).abs <= tolerance.to_i
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def delivery_id(source, headers, body)
|
|
96
|
+
if source.delivery_id_header.present?
|
|
97
|
+
value = header(headers, source.delivery_id_header)
|
|
98
|
+
return value if value.present?
|
|
99
|
+
end
|
|
100
|
+
# No delivery-id header configured (or absent): fall back to a body hash.
|
|
101
|
+
# Consequence: identical bodies dedupe. A sender with a real delivery id
|
|
102
|
+
# should configure delivery_id_header so distinct-but-identical bodies pass.
|
|
103
|
+
Digest::SHA256.hexdigest(body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def header(headers, name)
|
|
107
|
+
return nil if name.blank?
|
|
108
|
+
|
|
109
|
+
# ActionDispatch::Http::Headers is case-insensitive on []; a plain Hash
|
|
110
|
+
# (tests) is not — try the given key then a couple of common casings.
|
|
111
|
+
headers[name] || headers[name.to_s] || headers[name.to_s.downcase] ||
|
|
112
|
+
headers["HTTP_#{name.to_s.upcase.tr('-', '_')}"]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse(raw_body)
|
|
116
|
+
JSON.parse(raw_body)
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
{ "raw" => raw_body }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def unauthorized(reason)
|
|
122
|
+
Result.new(status: 401, reason: reason)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|