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,224 @@
1
+ module Admin
2
+ class BaseController < ActionController::Base
3
+ include Pagination
4
+
5
+ layout "admin"
6
+
7
+ before_action :set_loam_context
8
+ before_action :set_locale
9
+
10
+ rescue_from Loam::NotAuthorizedError do
11
+ render plain: "403 Forbidden — your role does not permit this action.", status: :forbidden
12
+ end
13
+
14
+ # A disabled feature is "not here" for this tenant, so it 404s — unlike a
15
+ # policy refusal (403), which is "you may not". Capability vs. person.
16
+ rescue_from Loam::FeatureDisabledError do
17
+ render plain: "404 Not Found — this feature is not enabled for your tenant.", status: :not_found
18
+ end
19
+
20
+ # A business rule (or any code) can veto a workflow transition from
21
+ # `loam_perform_transition!`. Surface it as a clean 422, never a 500.
22
+ rescue_from Loam::TransitionVetoedError do |error|
23
+ render plain: "422 — #{error.message}", status: :unprocessable_entity
24
+ end
25
+
26
+ helper_method :current_tenant, :current_actor, :unread_notification_count, :feature_on?, :pending_approval_count, :current_role, :current_locale, :sort_link, :can?
27
+
28
+ private
29
+
30
+ # A clickable, stateful column header for the generated index tables (L-401):
31
+ # toggles asc/desc, shows the active-direction arrow, and carries the current
32
+ # search / filter / saved-view state so sorting composes with paging and
33
+ # filtering. The controller whitelists the column against real columns, so a
34
+ # crafted `sort` param can never reach ORDER BY.
35
+ def sort_link(column, label)
36
+ active = params[:sort].to_s == column.to_s
37
+ ascending = active && params[:dir].to_s.casecmp("asc").zero?
38
+ arrow = active ? (ascending ? " ↑" : " ↓") : ""
39
+ carried = respond_to?(:current_index_params, true) ? current_index_params : {}
40
+ helpers.link_to("#{label}#{arrow}", url_for(carried.merge(sort: column, dir: ascending ? "desc" : "asc")))
41
+ end
42
+
43
+ # Locale is request state like the tenant: from the switcher (param),
44
+ # remembered in the session, defaulting to Loam.default_locale. Content
45
+ # translations (Loam::Translatable) overlay onto it.
46
+ def set_locale
47
+ session[:locale] = params[:locale] if params[:locale].present? && Loam.locales.include?(params[:locale])
48
+ Loam::Current.locale = session[:locale] || Loam.default_locale
49
+ # Drive Rails i18n (UI strings) off the same choice, so the switcher moves
50
+ # both the chrome and the content — but only to a locale that actually has
51
+ # translations, so an unlisted locale keeps English UI (data still overlays).
52
+ chosen = Loam::Current.locale.to_s
53
+ I18n.locale = chosen if I18n.available_locales.map(&:to_s).include?(chosen)
54
+ end
55
+
56
+ def current_locale = Loam::Current.locale
57
+
58
+ def current_tenant = Loam::Current.tenant
59
+ def current_actor = Loam::Current.actor
60
+
61
+ # Establishes Loam::Current from the session, then proves the actor is
62
+ # allowed to be here.
63
+ #
64
+ # The order is load-bearing. Loam::Membership is itself tenant-scoped, so
65
+ # "is this actor a member?" is a question you can only ask from inside a
66
+ # tenant — the tenant has to be in Loam::Current first, or the check raises
67
+ # Loam::MissingTenantError instead of answering. The `&&` chain below
68
+ # guarantees that: membership is only consulted once both are set.
69
+ #
70
+ # If anything fails, BOTH halves of the context are cleared before the
71
+ # redirect, so a half-established context can never leak into the next
72
+ # request or into a job enqueued from it.
73
+ def set_loam_context
74
+ Loam::Current.tenant = Loam::Tenant.find_by(id: session[:tenant_id])
75
+ Loam::Current.actor = User.find_by(id: session[:user_id])
76
+
77
+ return if current_tenant && current_actor && member_of_current_tenant?
78
+
79
+ Loam::Current.reset
80
+ redirect_to new_admin_session_path
81
+ end
82
+
83
+ # Scoped to the current tenant by Loam::TenantRecord, which is the whole
84
+ # point: a membership in some other tenant is not membership here.
85
+ def member_of_current_tenant?
86
+ Loam::Membership.exists?(user_id: current_actor.id)
87
+ end
88
+
89
+ # Drives the bell in the admin layout. One COUNT per admin page render,
90
+ # which is fine at this scale; cache it if a screen ever gets hot.
91
+ def unread_notification_count
92
+ return 0 unless current_actor
93
+
94
+ Loam::Notification.unread.where(user_id: current_actor.id).count
95
+ end
96
+
97
+ # Staged changes awaiting review, for the "Approvals (N)" nav badge.
98
+ def pending_approval_count
99
+ return 0 unless current_tenant
100
+
101
+ Loam::PendingAction.pending.count
102
+ end
103
+
104
+ def policy_for(record)
105
+ Loam::Policy.for(record)
106
+ end
107
+
108
+ def authorize!(policy, action)
109
+ raise Loam::NotAuthorizedError unless policy.public_send(action)
110
+ end
111
+
112
+ # For admin screens with no per-record policy (e.g. field definitions,
113
+ # which apply to a whole entity_type rather than one record).
114
+ def current_role
115
+ Loam::Membership.find_by(user_id: current_actor&.id)&.role&.to_sym
116
+ end
117
+
118
+ def require_role!(*roles)
119
+ raise Loam::NotAuthorizedError unless roles.include?(current_role)
120
+ end
121
+
122
+ # Feature-string permission gate (Loam::Permissions) — a finer capability
123
+ # check than require_role!. `can?` is a helper_method so views hide UI a role
124
+ # lacks; require_permission! enforces it (403) server-side.
125
+ def can?(permission)
126
+ Loam.can?(permission)
127
+ end
128
+
129
+ def require_permission!(permission)
130
+ raise Loam::NotAuthorizedError unless Loam.can?(permission)
131
+ end
132
+
133
+ # Feature guards. These gate a CAPABILITY (is the feature on for this
134
+ # tenant), orthogonal to require_role! / policies, which gate a PERSON.
135
+ # `feature_on?` is a helper_method, so views can hide UI a flag turns off.
136
+ def feature_on?(name)
137
+ Loam::Features.on?(name)
138
+ end
139
+
140
+ def require_feature!(name)
141
+ raise Loam::FeatureDisabledError unless Loam::Features.on?(name)
142
+ end
143
+
144
+ # Optimistic-locking conflict handling: someone saved this record between the
145
+ # user opening the edit form and submitting it (lock_version mismatch →
146
+ # StaleObjectError). Build a field-level diff of what the user tried to write
147
+ # vs the current saved values, then RELOAD to fresh data so the retry carries
148
+ # the new lock_version — never a 500, never a silent clobber. lock_version is
149
+ # the guarantee; the advisory Loam::RecordLock is only a courtesy warning.
150
+ #
151
+ # Encrypted fields are compared and shown via the reader (decrypted); the raw
152
+ # column is ciphertext that never matches (random IV), so a byte compare would
153
+ # be a false conflict every time.
154
+ def stale_conflict!(record, fields)
155
+ encrypted = record.class.respond_to?(:loam_encrypted_attributes) ? record.class.loam_encrypted_attributes.map(&:to_s) : []
156
+ attempted = fields.map(&:to_s).index_with { |field| conflict_value(record, field, encrypted) }
157
+
158
+ record.reload
159
+
160
+ @conflict = fields.map(&:to_s).each_with_object({}) do |field, diff|
161
+ theirs = conflict_value(record, field, encrypted)
162
+ yours = attempted[field]
163
+ diff[field] = { "yours" => yours, "theirs" => theirs } unless yours.to_s == theirs.to_s
164
+ end
165
+ flash.now[:alert] = "This #{record.model_name.human.downcase} changed since you opened it — review the differences and save again."
166
+ end
167
+
168
+ def conflict_value(record, field, encrypted)
169
+ encrypted.include?(field) ? record.public_send(field) : record[field]
170
+ end
171
+
172
+ # Step-up ("sudo") auth. Orthogonal to role: even a manager re-confirms for a
173
+ # sensitive action if their last authentication was more than SUDO_WINDOW ago.
174
+ # A fresh login or MFA verification stamps session[:sudo_at]; this re-checks
175
+ # it and, when stale, detours through the re-challenge and comes back.
176
+ SUDO_WINDOW = 5.minutes
177
+
178
+ def require_sudo!
179
+ return if sudo_fresh?
180
+
181
+ # A GET can be replayed after re-auth; a non-GET (a DELETE button) cannot,
182
+ # so we come back to the page it was on and the user repeats the action.
183
+ session[:sudo_return_to] = request.get? ? request.fullpath : request.referer
184
+ redirect_to new_admin_sudo_path
185
+ end
186
+
187
+ def sudo_fresh?
188
+ authenticated_at = session[:sudo_at]
189
+ authenticated_at.present? && Time.now.to_i - authenticated_at.to_i < SUDO_WINDOW.to_i
190
+ end
191
+
192
+ # Attaching a file changes the record, so it is an update: a role that may
193
+ # not update this record may not put files on it either.
194
+ #
195
+ # A `multiple: true` file field posts an empty string alongside any real
196
+ # files, so blanks are dropped BEFORE the authorization check — submitting
197
+ # a form with no file chosen is not an attempt to upload.
198
+ def attach_files!(record, policy)
199
+ submitted = Array(params.dig(record.model_name.param_key, :files)).reject(&:blank?)
200
+ return if submitted.empty?
201
+
202
+ raise Loam::NotAuthorizedError unless policy.update?
203
+
204
+ record.files.attach(submitted)
205
+ end
206
+
207
+ # Runtime custom fields (see Loam::CustomFields) go through the same
208
+ # field-level enforcement as real columns: only a writable definition's
209
+ # value is ever assigned.
210
+ def assign_custom_fields!(record, params, policy)
211
+ # The form partial nests inputs under the model's param key
212
+ # (equipment[custom_fields][serial_number]), so the values must be read
213
+ # from there — a top-level params[:custom_fields] read silently saves
214
+ # nothing while the redirect still says success. Found by an agent
215
+ # during the first golden-tasks benchmark run.
216
+ submitted = params.dig(record.model_name.param_key, :custom_fields)
217
+ return unless submitted
218
+
219
+ submitted.each do |name, value|
220
+ record.set_custom_field(name, value) if policy.custom_field_writable?(name)
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,64 @@
1
+ module Admin
2
+ # Managing business rules (Loam::BusinessRule) — manager-only, since a rule can
3
+ # notify, emit events, and set fields across the tenant. The condition and
4
+ # actions are edited as JSON for the prototype (a visual builder is a future
5
+ # increment). The recent execution log shows WHY rules fired.
6
+ class BusinessRulesController < BaseController
7
+ before_action { require_role!(:manager) }
8
+ before_action :set_rule, only: %i[edit update destroy]
9
+
10
+ rescue_from JSON::ParserError do |error|
11
+ flash.now[:alert] = "Condition and actions must be valid JSON: #{error.message}"
12
+ @rule ||= Loam::BusinessRule.new
13
+ render(@rule.persisted? ? :edit : :new, status: :unprocessable_entity)
14
+ end
15
+
16
+ def index
17
+ @rules = Loam::BusinessRule.by_priority
18
+ @recent_runs = Loam::BusinessRuleRun.recent.limit(20)
19
+ end
20
+
21
+ def new
22
+ @rule = Loam::BusinessRule.new(active: true, condition: {}, actions: [])
23
+ end
24
+
25
+ def create
26
+ @rule = Loam::BusinessRule.new(rule_params)
27
+ if @rule.save
28
+ redirect_to admin_business_rules_path, notice: "Rule created."
29
+ else
30
+ render :new, status: :unprocessable_entity
31
+ end
32
+ end
33
+
34
+ def edit; end
35
+
36
+ def update
37
+ if @rule.update(rule_params)
38
+ redirect_to admin_business_rules_path, notice: "Rule updated."
39
+ else
40
+ render :edit, status: :unprocessable_entity
41
+ end
42
+ end
43
+
44
+ def destroy
45
+ @rule.destroy!
46
+ redirect_to admin_business_rules_path, notice: "Rule deleted."
47
+ end
48
+
49
+ private
50
+
51
+ def set_rule
52
+ @rule = Loam::BusinessRule.find(params[:id])
53
+ end
54
+
55
+ # Parse the JSON textareas here; a malformed value raises JSON::ParserError,
56
+ # which the rescue above turns into a form error (never a 500).
57
+ def rule_params
58
+ attributes = params.require(:business_rule).permit(:name, :entity_type, :trigger, :active, :priority)
59
+ attributes[:condition] = JSON.parse(params[:business_rule][:condition].presence || "{}")
60
+ attributes[:actions] = JSON.parse(params[:business_rule][:actions].presence || "[]")
61
+ attributes
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,3 @@
1
+ <h1>Edit rule</h1>
2
+
3
+ <%%= render "form" %>
@@ -0,0 +1,32 @@
1
+ <%% if @error || flash[:alert] %>
2
+ <p style="color:#b00"><%%= @error || flash[:alert] %></p>
3
+ <%% end %>
4
+
5
+ <%# The model is namespaced (Loam::BusinessRule), so an inferred URL/param-scope
6
+ would be admin_loam_business_rule_path / loam_business_rule[...]. Pin both to
7
+ the un-namespaced route and the "business_rule" key the controller permits. %>
8
+ <%%= form_with model: @rule, scope: :business_rule,
9
+ url: (@rule.persisted? ? admin_business_rule_path(@rule) : admin_business_rules_path) do |f| %>
10
+ <%% if @rule.errors.any? %>
11
+ <ul><%% @rule.errors.full_messages.each do |m| %><li><%%= m %></li><%% end %></ul>
12
+ <%% end %>
13
+
14
+ <p><%%= f.label :name %><br><%%= f.text_field :name %></p>
15
+ <p><%%= f.label :trigger, "Trigger (event pattern, e.g. billing.invoice.submit)" %><br><%%= f.text_field :trigger %></p>
16
+ <p><%%= f.label :entity_type, "Entity type (e.g. Invoice; blank for event-only)" %><br><%%= f.text_field :entity_type %></p>
17
+ <p><%%= f.label :priority %><br><%%= f.number_field :priority %></p>
18
+ <p><%%= f.label :active %> <%%= f.check_box :active %></p>
19
+
20
+ <p>
21
+ <%%= f.label :condition, "Condition (JSON)" %><br>
22
+ <%%= f.text_area :condition, value: JSON.pretty_generate(@rule.condition_tree), rows: 6, cols: 60 %>
23
+ </p>
24
+ <p>
25
+ <%%= f.label :actions, "Actions (JSON list)" %><br>
26
+ <%%= f.text_area :actions, value: JSON.pretty_generate(@rule.action_list), rows: 6, cols: 60 %>
27
+ </p>
28
+
29
+ <%%= f.submit %>
30
+ <%% end %>
31
+
32
+ <p><%%= link_to "Back", admin_business_rules_path %></p>
@@ -0,0 +1,45 @@
1
+ <h1>Business rules</h1>
2
+
3
+ <p><%%= link_to "New rule", new_admin_business_rule_path %></p>
4
+
5
+ <table>
6
+ <thead>
7
+ <tr><th>Name</th><th>Trigger</th><th>Entity</th><th>Priority</th><th>Active</th><th></th></tr>
8
+ </thead>
9
+ <tbody>
10
+ <%% @rules.each do |rule| %>
11
+ <tr>
12
+ <td><%%= rule.name %></td>
13
+ <td><code><%%= rule.trigger %></code></td>
14
+ <td><%%= rule.entity_type %></td>
15
+ <td><%%= rule.priority %></td>
16
+ <td><%%= rule.active? ? "yes" : "no" %></td>
17
+ <td>
18
+ <%%= link_to "Edit", edit_admin_business_rule_path(rule) %>
19
+ <%%= button_to "Delete", admin_business_rule_path(rule), method: :delete, form: { style: "display:inline" } %>
20
+ </td>
21
+ </tr>
22
+ <%% end %>
23
+ </tbody>
24
+ </table>
25
+
26
+ <h2>Recent runs</h2>
27
+ <p>Why rules did (or didn't) act — the last 20.</p>
28
+ <table>
29
+ <thead>
30
+ <tr><th>When</th><th>Rule</th><th>Subject</th><th>Event</th><th>Matched</th><th>Actions</th><th>Error</th></tr>
31
+ </thead>
32
+ <tbody>
33
+ <%% @recent_runs.each do |run| %>
34
+ <tr>
35
+ <td><%%= run.created_at.strftime("%H:%M:%S") %></td>
36
+ <td><%%= run.business_rule.name %></td>
37
+ <td><%%= [run.subject_type, run.subject_id].compact.join(" #") %></td>
38
+ <td><code><%%= run.event_name %></code></td>
39
+ <td><%%= run.matched ? "yes" : "no" %></td>
40
+ <td><%%= Array(run.actions_taken).join(", ") %></td>
41
+ <td><%%= run.error %></td>
42
+ </tr>
43
+ <%% end %>
44
+ </tbody>
45
+ </table>
@@ -0,0 +1,3 @@
1
+ <h1>New rule</h1>
2
+
3
+ <%%= render "form" %>
@@ -0,0 +1,39 @@
1
+ module Admin
2
+ # One controller for every commentable entity, because a comment form is the
3
+ # same form whatever it is attached to.
4
+ #
5
+ # Create only: comments are a record of what was said, so there is no edit
6
+ # and no delete here.
7
+ class CommentsController < BaseController
8
+ def create
9
+ record = commentable
10
+
11
+ # Commenting is open to any member — and membership is already proven:
12
+ # BaseController refuses the request otherwise. The policy check is the
13
+ # record's own, so an entity that hides itself from a role hides its
14
+ # discussion too.
15
+ authorize!(policy_for(record), :read?)
16
+
17
+ comment = record.loam_comments.new(body: params[:body], author: current_actor)
18
+ flash[:alert] = comment.errors.full_messages.to_sentence unless comment.save
19
+
20
+ redirect_back fallback_location: [ :admin, record ]
21
+ end
22
+
23
+ private
24
+
25
+ # The type arrives from the browser, so it is never constantized blindly.
26
+ # `safe_constantize` returns nil for anything that is not a real constant,
27
+ # and the class then has to prove it is a Loam entity that opted into
28
+ # comments — the allowlist is the contract, not a hand-kept list of names.
29
+ def commentable
30
+ klass = params[:commentable_type].to_s.safe_constantize
31
+
32
+ unless klass.is_a?(Class) && klass < Loam::TenantRecord && klass.include?(Loam::Commentable)
33
+ raise Loam::NotAuthorizedError, "#{params[:commentable_type].inspect} is not a commentable Loam entity"
34
+ end
35
+
36
+ klass.find(params[:commentable_id])
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ module Admin
2
+ # Per-tenant settings (Loam::Configs). Structural, not per-record, so it is
3
+ # gated on the manager role rather than an entity policy — settings are
4
+ # privileged. Everything routes through Loam::Configs, never Loam::Config
5
+ # directly, so the cross-level resolution stays in vetted gem code.
6
+ class ConfigsController < BaseController
7
+ before_action { require_role!(:manager) }
8
+
9
+ def index
10
+ # Feature flags share the loam_configs store but have their own screen
11
+ # (/admin/features), so hide their reserved-prefix keys from Settings.
12
+ keys = Loam::Configs.defined_keys.reject { |key| key.start_with?(Loam::Features::PREFIX) }
13
+ @settings = keys.map do |key|
14
+ { key: key, value: Loam::Configs.get(key), overridden: Loam::Configs.overridden?(key) }
15
+ end
16
+ end
17
+
18
+ def edit
19
+ @key = params[:key]
20
+ @value = Loam::Configs.get(@key)
21
+ @overridden = Loam::Configs.overridden?(@key)
22
+ end
23
+
24
+ def update
25
+ Loam::Configs.set(params[:key], coerce(params[:value]))
26
+ redirect_to admin_configs_path, notice: "#{params[:key]} set for this tenant."
27
+ end
28
+
29
+ def reset
30
+ Loam::Configs.reset(params[:key])
31
+ redirect_to admin_configs_path, notice: "#{params[:key]} reset to the default."
32
+ end
33
+
34
+ private
35
+
36
+ # The form is a single text field, so values arrive as strings. Parse them
37
+ # as JSON so numbers/booleans/objects keep their type; anything that is not
38
+ # valid JSON (a bare word like PLN) is stored as the string it is. Known
39
+ # quirk: typing 25 stores the number, never the string "25" — fine here.
40
+ def coerce(raw)
41
+ JSON.parse(raw)
42
+ rescue JSON::ParserError
43
+ raw
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ <h1>Setting: <%%= @key %></h1>
2
+
3
+ <p>Overriding for <strong><%%= current_tenant.name %></strong>.
4
+ Current value: <code><%%= @value.inspect %></code>
5
+ (<%%= @overridden ? "overridden here" : "inherited default" %>).</p>
6
+
7
+ <%%= form_with url: admin_configs_path, method: :patch do |f| %>
8
+ <%%= hidden_field_tag :key, @key %>
9
+ <p>
10
+ <%%= label_tag :value, "Value" %><br>
11
+ <%%= text_field_tag :value, @value.is_a?(String) ? @value : @value.to_json %>
12
+ </p>
13
+ <p class="hint">Numbers, true/false and JSON objects keep their type; plain text is stored as a string.</p>
14
+ <%%= f.submit "Save override" %>
15
+ <%% end %>
16
+
17
+ <p>
18
+ <%% if @overridden %>
19
+ <%%= button_to "Reset to default", admin_configs_path(key: @key), method: :delete %>
20
+ <%% end %>
21
+ <%%= link_to "Back", admin_configs_path %>
22
+ </p>
@@ -0,0 +1,30 @@
1
+ <h1>Settings</h1>
2
+
3
+ <p>Values resolve override → global → declared default. Editing sets an override
4
+ for <strong><%%= current_tenant.name %></strong> only; Reset drops it.</p>
5
+
6
+ <table>
7
+ <thead>
8
+ <tr>
9
+ <th>Key</th>
10
+ <th>Value (this tenant)</th>
11
+ <th>Source</th>
12
+ <th></th>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ <%% @settings.each do |setting| %>
17
+ <tr>
18
+ <td><%%= setting[:key] %></td>
19
+ <td><%%= setting[:value].inspect %></td>
20
+ <td><%%= setting[:overridden] ? "override" : "default" %></td>
21
+ <td>
22
+ <%%= link_to "Edit", admin_edit_config_path(key: setting[:key]) %>
23
+ <%% if setting[:overridden] %>
24
+ <%%= button_to "Reset", admin_configs_path(key: setting[:key]), method: :delete, form: { style: "display:inline" } %>
25
+ <%% end %>
26
+ </td>
27
+ </tr>
28
+ <%% end %>
29
+ </tbody>
30
+ </table>
@@ -0,0 +1,10 @@
1
+ module Admin
2
+ class DashboardController < BaseController
3
+ def index
4
+ # Configurable, role-visible widgets (Loam::Dashboard) — a raising widget
5
+ # is isolated into an error tile, never breaking the page. Register your
6
+ # own with Loam::Widgets.register; arrange them under Dashboard settings.
7
+ @widgets = Loam::Dashboard.for(actor: current_actor, role: current_role)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ <h1>Dashboard</h1>
2
+
3
+ <%% if current_role == :manager %>
4
+ <p><%%= link_to "Dashboard settings", admin_dashboard_widgets_path %></p>
5
+ <%% end %>
6
+
7
+ <div class="widgets" style="display:flex; flex-wrap:wrap; gap:1rem">
8
+ <%% @widgets.each do |widget| %>
9
+ <div class="widget" style="border:1px solid #ccc; padding:0.75rem; min-width:200px">
10
+ <h3><%%= widget[:title] %></h3>
11
+ <%% if widget[:error] %>
12
+ <p style="color:#b00">This widget could not load.</p>
13
+ <%% else %>
14
+ <%% data = widget[:data] %>
15
+ <%% case data[:kind] %>
16
+ <%% when "count" %>
17
+ <p style="font-size:2rem; margin:0"><%%= data[:value] %></p>
18
+ <%% when "list" %>
19
+ <ul><%% data[:items].each do |item| %><li><%%= item %></li><%% end %></ul>
20
+ <%% else %>
21
+ <p><%%= data.inspect %></p>
22
+ <%% end %>
23
+ <%% end %>
24
+ </div>
25
+ <%% end %>
26
+ </div>
@@ -0,0 +1,23 @@
1
+ module Admin
2
+ # Dashboard settings (Loam::DashboardWidget) — manager-only. Choose which
3
+ # registered widgets appear and in what order for this tenant.
4
+ class DashboardWidgetsController < BaseController
5
+ before_action { require_role!(:manager) }
6
+
7
+ def index
8
+ @registered = Loam::Widgets.registered
9
+ @config = Loam::DashboardWidget.all.index_by(&:widget_key)
10
+ end
11
+
12
+ def update
13
+ Loam::Widgets.keys.each do |key|
14
+ attrs = params.dig(:widgets, key) || {}
15
+ row = Loam::DashboardWidget.find_or_initialize_by(widget_key: key)
16
+ row.active = attrs[:active] == "1"
17
+ row.position = attrs[:position].to_i
18
+ row.save!
19
+ end
20
+ redirect_to admin_root_path, notice: "Dashboard updated."
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ <h1>Dashboard settings</h1>
2
+
3
+ <p>Choose which widgets appear on the dashboard and their order (lower position
4
+ first). Leaving none active shows the default set.</p>
5
+
6
+ <%%= form_with url: admin_dashboard_widgets_path, method: :patch do %>
7
+ <table>
8
+ <thead><tr><th>Show</th><th>Widget</th><th>Roles</th><th>Position</th></tr></thead>
9
+ <tbody>
10
+ <%% @registered.each do |widget| %>
11
+ <%% row = @config[widget.key] %>
12
+ <tr>
13
+ <td><%%= check_box_tag "widgets[#{widget.key}][active]", "1", row.nil? || row.active %></td>
14
+ <td><%%= widget.title %> <small>(<code><%%= widget.key %></code>)</small></td>
15
+ <td><%%= widget.roles ? Array(widget.roles).join(", ") : "all" %></td>
16
+ <td><%%= number_field_tag "widgets[#{widget.key}][position]", row&.position || 0, style: "width:4rem" %></td>
17
+ </tr>
18
+ <%% end %>
19
+ </tbody>
20
+ </table>
21
+ <%%= submit_tag "Save dashboard" %>
22
+ <%% end %>
23
+
24
+ <p><%%= link_to "Back to dashboard", admin_root_path %></p>
@@ -0,0 +1,55 @@
1
+ module Admin
2
+ # Managed lookup lists (Loam::Dictionary) — manager-only. The index lists a
3
+ # tenant's dictionaries; the edit screen curates a dictionary's entries
4
+ # (Admin::DictionaryEntriesController). Tenant-scoped by the model's default
5
+ # scope, so a manager only ever sees their own tenant's lists.
6
+ class DictionariesController < BaseController
7
+ before_action { require_role!(:manager) }
8
+ before_action :set_dictionary, only: %i[edit update destroy]
9
+
10
+ def index
11
+ @dictionaries = Loam::Dictionary.order(:key)
12
+ end
13
+
14
+ def new
15
+ @dictionary = Loam::Dictionary.new
16
+ end
17
+
18
+ def create
19
+ @dictionary = Loam::Dictionary.new(dictionary_params)
20
+ if @dictionary.save
21
+ redirect_to edit_admin_dictionary_path(@dictionary), notice: "Dictionary created — add its entries below."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def edit
28
+ @entry = @dictionary.entries.new(active: true)
29
+ end
30
+
31
+ def update
32
+ if @dictionary.update(dictionary_params)
33
+ redirect_to admin_dictionaries_path, notice: "Dictionary updated."
34
+ else
35
+ @entry = @dictionary.entries.new
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @dictionary.destroy!
42
+ redirect_to admin_dictionaries_path, notice: "Dictionary deleted."
43
+ end
44
+
45
+ private
46
+
47
+ def set_dictionary
48
+ @dictionary = Loam::Dictionary.find(params[:id])
49
+ end
50
+
51
+ def dictionary_params
52
+ params.require(:dictionary).permit(:key, :name)
53
+ end
54
+ end
55
+ end