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.
Files changed (245) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +71 -0
  3. data/LICENSE +21 -0
  4. data/README.md +177 -0
  5. data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
  6. data/app/jobs/loam/event_delivery_job.rb +26 -0
  7. data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
  8. data/app/jobs/loam/webhook_delivery_job.rb +59 -0
  9. data/app/models/loam/api_token.rb +40 -0
  10. data/app/models/loam/audit_record.rb +18 -0
  11. data/app/models/loam/auth_attempt.rb +13 -0
  12. data/app/models/loam/business_rule.rb +49 -0
  13. data/app/models/loam/business_rule_run.rb +27 -0
  14. data/app/models/loam/comment.rb +28 -0
  15. data/app/models/loam/config.rb +27 -0
  16. data/app/models/loam/custom_field_value.rb +18 -0
  17. data/app/models/loam/dashboard_widget.rb +16 -0
  18. data/app/models/loam/dictionary.rb +20 -0
  19. data/app/models/loam/dictionary_entry.rb +31 -0
  20. data/app/models/loam/event_delivery.rb +38 -0
  21. data/app/models/loam/field_definition.rb +48 -0
  22. data/app/models/loam/inbound_webhook_delivery.rb +24 -0
  23. data/app/models/loam/inbound_webhook_source.rb +55 -0
  24. data/app/models/loam/membership.rb +27 -0
  25. data/app/models/loam/mfa_credential.rb +114 -0
  26. data/app/models/loam/notification.rb +36 -0
  27. data/app/models/loam/pending_action.rb +208 -0
  28. data/app/models/loam/perspective.rb +104 -0
  29. data/app/models/loam/progress_job.rb +103 -0
  30. data/app/models/loam/record_lock.rb +19 -0
  31. data/app/models/loam/scheduled_job.rb +42 -0
  32. data/app/models/loam/search_token.rb +9 -0
  33. data/app/models/loam/sso_identity.rb +13 -0
  34. data/app/models/loam/sso_provider.rb +35 -0
  35. data/app/models/loam/tenant.rb +20 -0
  36. data/app/models/loam/translation.rb +17 -0
  37. data/app/models/loam/webhook_endpoint.rb +26 -0
  38. data/app/views/loam/attachments/_attachments.html.erb +16 -0
  39. data/app/views/loam/comments/_comments.html.erb +24 -0
  40. data/app/views/loam/custom_fields/_fields.html.erb +35 -0
  41. data/lib/generators/loam/entity/entity_generator.rb +160 -0
  42. data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
  43. data/lib/generators/loam/entity/templates/controller.rb +181 -0
  44. data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
  45. data/lib/generators/loam/entity/templates/migration.rb +26 -0
  46. data/lib/generators/loam/entity/templates/model.rb +27 -0
  47. data/lib/generators/loam/entity/templates/policy.rb +10 -0
  48. data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
  49. data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
  50. data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
  51. data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
  52. data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
  53. data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
  54. data/lib/generators/loam/install/install_generator.rb +288 -0
  55. data/lib/generators/loam/install/templates/AGENTS.md +341 -0
  56. data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
  57. data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
  58. data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
  59. data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
  60. data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
  61. data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
  62. data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
  63. data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
  64. data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
  65. data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
  66. data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
  67. data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
  68. data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
  69. data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
  70. data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
  71. data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
  72. data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
  73. data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
  74. data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
  75. data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
  76. data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
  77. data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
  78. data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
  79. data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
  80. data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
  81. data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
  82. data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
  83. data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
  84. data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
  85. data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
  86. data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
  87. data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
  88. data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
  89. data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
  90. data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
  91. data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
  92. data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
  93. data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
  94. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
  95. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
  96. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
  97. data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
  98. data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
  99. data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
  100. data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
  101. data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
  102. data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
  103. data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
  104. data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
  105. data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
  106. data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
  107. data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
  108. data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
  109. data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
  110. data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
  111. data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
  112. data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
  113. data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
  114. data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
  115. data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
  116. data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
  117. data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
  118. data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
  119. data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
  120. data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
  121. data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
  122. data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
  123. data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
  124. data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
  125. data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
  126. data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
  127. data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
  128. data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
  129. data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
  130. data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
  131. data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
  132. data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
  133. data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
  134. data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
  135. data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
  136. data/lib/generators/loam/install/templates/admin.css +29 -0
  137. data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
  138. data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
  139. data/lib/generators/loam/install/templates/import_job.rb +25 -0
  140. data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
  141. data/lib/generators/loam/install/templates/initializer.rb +218 -0
  142. data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
  143. data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
  144. data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
  145. data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
  146. data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
  147. data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
  148. data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
  149. data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
  150. data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
  151. data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
  152. data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
  153. data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
  154. data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
  155. data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
  156. data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
  157. data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
  158. data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
  159. data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
  160. data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
  161. data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
  162. data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
  163. data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
  164. data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
  165. data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
  166. data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
  167. data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
  168. data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
  169. data/lib/generators/loam/install/templates/user.rb +21 -0
  170. data/lib/generators/loam/primary_key_options.rb +101 -0
  171. data/lib/loam/attachable.rb +27 -0
  172. data/lib/loam/auditable.rb +67 -0
  173. data/lib/loam/auth_throttle.rb +83 -0
  174. data/lib/loam/base32.rb +23 -0
  175. data/lib/loam/bulk.rb +59 -0
  176. data/lib/loam/business_rules/actions.rb +92 -0
  177. data/lib/loam/business_rules/condition.rb +102 -0
  178. data/lib/loam/business_rules.rb +123 -0
  179. data/lib/loam/commentable.rb +22 -0
  180. data/lib/loam/configs.rb +115 -0
  181. data/lib/loam/cron.rb +98 -0
  182. data/lib/loam/csv.rb +18 -0
  183. data/lib/loam/current.rb +21 -0
  184. data/lib/loam/custom_field_index.rb +262 -0
  185. data/lib/loam/custom_fields.rb +114 -0
  186. data/lib/loam/dashboard.rb +17 -0
  187. data/lib/loam/dictionaries.rb +55 -0
  188. data/lib/loam/durable_events.rb +174 -0
  189. data/lib/loam/encryptable.rb +145 -0
  190. data/lib/loam/encryption/cipher.rb +75 -0
  191. data/lib/loam/encryption/key_provider.rb +43 -0
  192. data/lib/loam/encryption.rb +125 -0
  193. data/lib/loam/engine.rb +57 -0
  194. data/lib/loam/enrichers.rb +105 -0
  195. data/lib/loam/errors.rb +49 -0
  196. data/lib/loam/eval.rb +47 -0
  197. data/lib/loam/event_stream.rb +80 -0
  198. data/lib/loam/eventful.rb +45 -0
  199. data/lib/loam/events.rb +51 -0
  200. data/lib/loam/export.rb +74 -0
  201. data/lib/loam/features.rb +75 -0
  202. data/lib/loam/generated_key.rb +37 -0
  203. data/lib/loam/import.rb +168 -0
  204. data/lib/loam/inbound_webhooks.rb +125 -0
  205. data/lib/loam/lifecycle.rb +180 -0
  206. data/lib/loam/locales/loam.en.yml +80 -0
  207. data/lib/loam/mcp/server.rb +60 -0
  208. data/lib/loam/mcp.rb +304 -0
  209. data/lib/loam/notifications.rb +36 -0
  210. data/lib/loam/open_api.rb +194 -0
  211. data/lib/loam/overrides.rb +110 -0
  212. data/lib/loam/pending_actions.rb +60 -0
  213. data/lib/loam/permissions.rb +79 -0
  214. data/lib/loam/perspectives.rb +62 -0
  215. data/lib/loam/policy.rb +106 -0
  216. data/lib/loam/progress.rb +31 -0
  217. data/lib/loam/record_locks.rb +89 -0
  218. data/lib/loam/scheduler.rb +153 -0
  219. data/lib/loam/search/driver.rb +31 -0
  220. data/lib/loam/search/like_driver.rb +29 -0
  221. data/lib/loam/search/token_driver.rb +88 -0
  222. data/lib/loam/search.rb +27 -0
  223. data/lib/loam/searchable.rb +90 -0
  224. data/lib/loam/soft_deletable.rb +72 -0
  225. data/lib/loam/sso/claims.rb +10 -0
  226. data/lib/loam/sso/fake_provider.rb +51 -0
  227. data/lib/loam/sso/http_client.rb +42 -0
  228. data/lib/loam/sso/oidc_provider.rb +64 -0
  229. data/lib/loam/sso.rb +129 -0
  230. data/lib/loam/telemetry.rb +43 -0
  231. data/lib/loam/tenant_record.rb +36 -0
  232. data/lib/loam/test_helpers.rb +15 -0
  233. data/lib/loam/totp.rb +63 -0
  234. data/lib/loam/translatable.rb +88 -0
  235. data/lib/loam/undo.rb +126 -0
  236. data/lib/loam/version.rb +3 -0
  237. data/lib/loam/webhooks.rb +34 -0
  238. data/lib/loam/widgets.rb +77 -0
  239. data/lib/loam/workflow.rb +256 -0
  240. data/lib/loam.rb +122 -0
  241. data/lib/open-loam.rb +5 -0
  242. data/lib/tasks/loam.rake +171 -0
  243. data/lib/tasks/loam_eval.rake +32 -0
  244. data/lib/tasks/loam_mcp.rake +20 -0
  245. 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
@@ -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
@@ -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
@@ -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