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,18 @@
1
+ <%% if @error %>
2
+ <p style="color:#b00"><%%= @error %></p>
3
+ <%% end %>
4
+
5
+ <h1>Two-factor authentication</h1>
6
+
7
+ <p>Signed in as <strong><%%= @user.name %></strong>. Enter the 6-digit code from
8
+ your authenticator app, or one of your recovery codes.</p>
9
+
10
+ <%%= form_with url: mfa_verify_admin_session_path, method: :post do |f| %>
11
+ <p>
12
+ <%%= label_tag :code, "Code" %><br>
13
+ <%%= text_field_tag :code, nil, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric" %>
14
+ </p>
15
+ <%%= f.submit "Verify" %>
16
+ <%% end %>
17
+
18
+ <%%= button_to "Cancel and sign out", admin_session_path, method: :delete %>
@@ -0,0 +1,45 @@
1
+ <%% if flash[:alert] %>
2
+ <p style="color:#b00"><%%= flash[:alert] %></p>
3
+ <%% end %>
4
+ <%% if @error %>
5
+ <p style="color:#b00"><%%= @error %></p>
6
+ <%% end %>
7
+
8
+ <%% if @user %>
9
+ <h1>Choose a tenant</h1>
10
+
11
+ <p>Signed in as <strong><%%= @user.name %></strong>. You are a member of more than one tenant.</p>
12
+
13
+ <%%= form_with url: select_tenant_admin_session_path, method: :post do |f| %>
14
+ <p>
15
+ <%%= select_tag :tenant_id, options_from_collection_for_select(@tenants, :id, :name) %>
16
+ </p>
17
+ <%%= f.submit "Enter" %>
18
+ <%% end %>
19
+
20
+ <%%= button_to "Sign out", admin_session_path, method: :delete %>
21
+ <%% else %>
22
+ <h1>Sign in</h1>
23
+
24
+ <%%= form_with url: admin_session_path, method: :post do |f| %>
25
+ <p>
26
+ <%%= label_tag :email %><br>
27
+ <%%= email_field_tag :email, params[:email], autofocus: true %>
28
+ </p>
29
+ <p>
30
+ <%%= label_tag :password %><br>
31
+ <%%= password_field_tag :password %>
32
+ </p>
33
+ <%%= f.submit "Sign in" %>
34
+ <%% end %>
35
+
36
+ <h2>Or sign in with SSO</h2>
37
+ <p>Enter your work email; if your organization has single sign-on, we'll send you to it.</p>
38
+ <%%= form_with url: sso_start_admin_session_path, method: :post do |f| %>
39
+ <p>
40
+ <%%= label_tag :sso_email, "Work email" %><br>
41
+ <%%= email_field_tag :email, params[:email] %>
42
+ </p>
43
+ <%%= f.submit "Continue with SSO" %>
44
+ <%% end %>
45
+ <%% end %>
@@ -0,0 +1,65 @@
1
+ module Admin
2
+ # Configure this tenant's SSO (OIDC) provider — manager-only, since it governs
3
+ # who can sign in and at what role. Tenant-scoped by Loam::SsoProvider's default
4
+ # scope, so a manager only ever sees and edits their own tenant's connection.
5
+ # The client_secret is WRITE-ONLY: it is encrypted at rest and never rendered
6
+ # back, so editing without retyping it leaves the stored secret untouched.
7
+ class SsoProvidersController < BaseController
8
+ before_action { require_role!(:manager) }
9
+ before_action :set_provider, only: %i[edit update destroy]
10
+
11
+ rescue_from JSON::ParserError do |error|
12
+ flash.now[:alert] = "The group→role map must be valid JSON: #{error.message}"
13
+ @provider ||= Loam::SsoProvider.new
14
+ render(@provider.persisted? ? :edit : :new, status: :unprocessable_entity)
15
+ end
16
+
17
+ def index
18
+ @providers = Loam::SsoProvider.order(:domain)
19
+ end
20
+
21
+ def new
22
+ @provider = Loam::SsoProvider.new(protocol: "oidc", active: true, jit_role: "employee")
23
+ end
24
+
25
+ def create
26
+ @provider = Loam::SsoProvider.new(provider_params)
27
+ if @provider.save
28
+ redirect_to admin_sso_providers_path, notice: "SSO provider created."
29
+ else
30
+ render :new, status: :unprocessable_entity
31
+ end
32
+ end
33
+
34
+ def edit; end
35
+
36
+ def update
37
+ if @provider.update(provider_params)
38
+ redirect_to admin_sso_providers_path, notice: "SSO provider updated."
39
+ else
40
+ render :edit, status: :unprocessable_entity
41
+ end
42
+ end
43
+
44
+ def destroy
45
+ @provider.destroy!
46
+ redirect_to admin_sso_providers_path, notice: "SSO provider deleted."
47
+ end
48
+
49
+ private
50
+
51
+ def set_provider
52
+ @provider = Loam::SsoProvider.find(params[:id])
53
+ end
54
+
55
+ def provider_params
56
+ permitted = params.require(:sso_provider).permit(:name, :protocol, :issuer, :client_id, :domain, :jit_role, :active)
57
+ # Write-only: only assign the secret when a new value was typed, so a blank
58
+ # field on edit keeps the encrypted one already stored.
59
+ secret = params[:sso_provider][:client_secret]
60
+ permitted[:client_secret] = secret if secret.present?
61
+ permitted[:group_role_map] = JSON.parse(params[:sso_provider][:group_role_map].presence || "{}")
62
+ permitted
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,3 @@
1
+ <h1>Edit SSO provider</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::SsoProvider), so an inferred URL/param-scope
6
+ would be admin_loam_sso_provider_path / loam_sso_provider[...]. Pin both to
7
+ the un-namespaced route and the "sso_provider" key the controller permits. %>
8
+ <%%= form_with model: @provider, scope: :sso_provider,
9
+ url: (@provider.persisted? ? admin_sso_provider_path(@provider) : admin_sso_providers_path) do |f| %>
10
+ <%% if @provider.errors.any? %>
11
+ <ul><%% @provider.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 :domain, "Email domain (for home-realm discovery, e.g. acme.example)" %><br><%%= f.text_field :domain %></p>
16
+ <p><%%= f.label :issuer, "OIDC issuer URL (its .well-known is discovered)" %><br><%%= f.text_field :issuer %></p>
17
+ <p><%%= f.label :client_id %><br><%%= f.text_field :client_id %></p>
18
+ <p>
19
+ <%%= f.label :client_secret, "Client secret (encrypted; leave blank to keep the current one)" %><br>
20
+ <%%= f.password_field :client_secret, value: "", autocomplete: "off" %>
21
+ </p>
22
+ <p><%%= f.label :jit_role, "Default role for new (JIT) users" %><br><%%= f.text_field :jit_role %></p>
23
+ <p>
24
+ <%%= f.label :group_role_map, "IdP group → role map (JSON, optional)" %><br>
25
+ <%%= f.text_area :group_role_map, value: JSON.pretty_generate(@provider.group_roles), rows: 4, cols: 40 %>
26
+ </p>
27
+ <p><%%= f.label :active %> <%%= f.check_box :active %></p>
28
+
29
+ <%%= f.submit %>
30
+ <%% end %>
31
+
32
+ <p><%%= link_to "Back", admin_sso_providers_path %></p>
@@ -0,0 +1,26 @@
1
+ <h1>SSO providers</h1>
2
+
3
+ <p>Single sign-on for this tenant. A user whose email domain matches a provider
4
+ below is sent to that identity provider to sign in (OIDC), and provisioned on
5
+ first login. <%%= link_to "New provider", new_admin_sso_provider_path %></p>
6
+
7
+ <table>
8
+ <thead>
9
+ <tr><th>Name</th><th>Protocol</th><th>Domain</th><th>JIT role</th><th>Active</th><th></th></tr>
10
+ </thead>
11
+ <tbody>
12
+ <%% @providers.each do |provider| %>
13
+ <tr>
14
+ <td><%%= provider.name %></td>
15
+ <td><%%= provider.protocol %></td>
16
+ <td><code><%%= provider.domain %></code></td>
17
+ <td><%%= provider.jit_role %></td>
18
+ <td><%%= provider.active? ? "yes" : "no" %></td>
19
+ <td>
20
+ <%%= link_to "Edit", edit_admin_sso_provider_path(provider) %>
21
+ <%%= button_to "Delete", admin_sso_provider_path(provider), method: :delete, form: { style: "display:inline" } %>
22
+ </td>
23
+ </tr>
24
+ <%% end %>
25
+ </tbody>
26
+ </table>
@@ -0,0 +1,3 @@
1
+ <h1>New SSO provider</h1>
2
+
3
+ <%%= render "form" %>
@@ -0,0 +1,45 @@
1
+ module Admin
2
+ # The step-up ("sudo") re-challenge. A sensitive action calls require_sudo!,
3
+ # which detours here when the last authentication is stale; a correct password
4
+ # (or TOTP code, if the user has MFA) stamps a fresh sudo timestamp and returns
5
+ # to wherever they were headed.
6
+ class SudoController < BaseController
7
+ def new
8
+ end
9
+
10
+ def create
11
+ identifier = "#{current_actor.email}:sudo" # a distinct throttle bucket from login
12
+
13
+ if Loam::AuthThrottle.locked?(identifier)
14
+ @error = "Too many attempts. Try again later."
15
+ return render :new, status: :too_many_requests
16
+ end
17
+
18
+ if reauthenticated?
19
+ Loam::AuthThrottle.clear(identifier)
20
+ session[:sudo_at] = Time.now.to_i
21
+ redirect_to(session.delete(:sudo_return_to).presence || admin_root_path,
22
+ notice: "Re-authenticated — you can complete the action now.")
23
+ else
24
+ Loam::AuthThrottle.record_failure(identifier, kind: "sudo", ip: request.remote_ip)
25
+ @error = "That did not verify. Try again."
26
+ render :new, status: :unauthorized
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # If the user has MFA, step-up takes a TOTP code — NOT a recovery code:
33
+ # recovery codes are single-use and reserved for LOGIN, so a routine sudo
34
+ # must never silently burn one. Otherwise fall back to the password. Either
35
+ # way it is the SAME person in Loam::Current re-confirming.
36
+ def reauthenticated?
37
+ credential = Loam::MfaCredential.active_for(current_actor)
38
+ if credential
39
+ credential.verify_totp(params[:code])
40
+ else
41
+ User.authenticate_by(email: current_actor.email, password: params[:password].to_s).present?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,24 @@
1
+ <%% if @error %>
2
+ <p style="color:#b00"><%%= @error %></p>
3
+ <%% end %>
4
+
5
+ <h1>Confirm it's you</h1>
6
+
7
+ <p>This action is sensitive, so please re-authenticate.</p>
8
+
9
+ <%%= form_with url: admin_sudo_path, method: :post do |f| %>
10
+ <%% if Loam::MfaCredential.active_for(current_actor) %>
11
+ <p>
12
+ <%%= label_tag :code, "Authenticator code" %><br>
13
+ <%%= text_field_tag :code, nil, autofocus: true, autocomplete: "one-time-code", inputmode: "numeric" %>
14
+ </p>
15
+ <%% else %>
16
+ <p>
17
+ <%%= label_tag :password %><br>
18
+ <%%= password_field_tag :password, nil, autofocus: true %>
19
+ </p>
20
+ <%% end %>
21
+ <%%= f.submit "Confirm" %>
22
+ <%% end %>
23
+
24
+ <p><%%= link_to "Cancel", admin_root_path %></p>
@@ -0,0 +1,37 @@
1
+ module Admin
2
+ # Per-record content translations (Loam::Translatable) — manager-only, since
3
+ # translating content is a privileged edit. Scoped by translatable_type +
4
+ # translatable_id; edits each translatable field across Loam.locales. The base
5
+ # (default-locale) value stays on the record itself and is shown read-only for
6
+ # reference — translations are additive.
7
+ class TranslationsController < BaseController
8
+ before_action { require_role!(:manager) }
9
+ before_action :set_record
10
+
11
+ def index; end
12
+
13
+ def update
14
+ params.require(:translations).to_unsafe_h.each do |field, by_locale|
15
+ next unless @record.class.loam_translatable?(field)
16
+
17
+ by_locale.each { |locale, value| @record.set_translation(field, locale, value) if Loam.locales.include?(locale) }
18
+ end
19
+ redirect_to translate_path, notice: "Translations saved."
20
+ end
21
+
22
+ private
23
+
24
+ def set_record
25
+ model = Loam::Import.allowed_model(params[:translatable_type]) # whitelist: a Loam entity only
26
+ raise ActiveRecord::RecordNotFound unless model.include?(Loam::Translatable)
27
+
28
+ @record = model.find(params[:translatable_id])
29
+ @fields = model.loam_translatable_attributes
30
+ end
31
+
32
+ def translate_path
33
+ admin_translations_path(translatable_type: @record.class.name, translatable_id: @record.id)
34
+ end
35
+ helper_method :translate_path
36
+ end
37
+ end
@@ -0,0 +1,21 @@
1
+ <h1>Translations — <%%= @record.class.model_name.human %> #<%%= @record.id %></h1>
2
+
3
+ <p>Translate content per locale. The base (<%%= Loam.default_locale.upcase %>) value
4
+ stays on the record; other locales overlay onto it, falling back to the base when
5
+ empty.</p>
6
+
7
+ <%%= form_with url: translate_path, method: :patch do %>
8
+ <%% @fields.each do |field| %>
9
+ <h3><%%= field.humanize %></h3>
10
+ <p><em>Base (<%%= Loam.default_locale.upcase %>):</em> <%%= @record.read_attribute(field) %></p>
11
+ <%% (Loam.locales - [Loam.default_locale]).each do |locale| %>
12
+ <p>
13
+ <%%= label_tag "translations[#{field}][#{locale}]", locale.upcase %><br>
14
+ <%%= text_field_tag "translations[#{field}][#{locale}]", @record.translations_for(field)[locale] %>
15
+ </p>
16
+ <%% end %>
17
+ <%% end %>
18
+ <%%= submit_tag "Save translations" %>
19
+ <%% end %>
20
+
21
+ <p><%%= link_to "Back", [:admin, @record] %></p>
@@ -0,0 +1,38 @@
1
+ module Admin
2
+ # Where this tenant's events get delivered. Structural rather than
3
+ # per-record, like field definitions, so it is gated on the manager role
4
+ # instead of a generated entity policy.
5
+ class WebhookEndpointsController < BaseController
6
+ before_action { require_role!(:manager) }
7
+
8
+ def index
9
+ @records = Loam::WebhookEndpoint.order(:event_pattern)
10
+ end
11
+
12
+ def new
13
+ @record = Loam::WebhookEndpoint.new(active: true)
14
+ end
15
+
16
+ def create
17
+ @record = Loam::WebhookEndpoint.new(permitted_params)
18
+
19
+ if @record.save
20
+ redirect_to admin_webhook_endpoints_path
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def destroy
27
+ Loam::WebhookEndpoint.find(params[:id]).destroy!
28
+ redirect_to admin_webhook_endpoints_path
29
+ end
30
+
31
+ private
32
+
33
+ # The secret is generated, never submitted.
34
+ def permitted_params
35
+ params.require(:webhook_endpoint).permit(:url, :event_pattern, :active)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ <h1>Webhook endpoints</h1>
2
+
3
+ <p>Domain events delivered over HTTP. A pattern ending in a dot matches a whole
4
+ domain ("rental."); anything else is one exact event name.</p>
5
+
6
+ <%%= link_to "New endpoint", new_admin_webhook_endpoint_path %>
7
+
8
+ <table>
9
+ <thead>
10
+ <tr>
11
+ <th>Event pattern</th>
12
+ <th>URL</th>
13
+ <th>Active</th>
14
+ <th>Secret</th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <%% @records.each do |record| %>
20
+ <tr>
21
+ <td><%%= record.event_pattern %></td>
22
+ <td><%%= record.url %></td>
23
+ <td><%%= record.active? ? "yes" : "no" %></td>
24
+ <td><code><%%= record.secret %></code></td>
25
+ <td><%%= button_to "Delete", admin_webhook_endpoint_path(record), method: :delete, form: { style: "display:inline" } %></td>
26
+ </tr>
27
+ <%% end %>
28
+ </tbody>
29
+ </table>
30
+
31
+ <p>Each delivery is signed: <code>X-Loam-Signature: sha256=HMAC-SHA256(secret, body)</code>.
32
+ Recompute it on your side and reject anything that does not match.</p>
@@ -0,0 +1,28 @@
1
+ <h1>New webhook endpoint</h1>
2
+
3
+ <%%= form_with model: @record, url: admin_webhook_endpoints_path do |f| %>
4
+ <%% if @record.errors.any? %>
5
+ <ul>
6
+ <%% @record.errors.full_messages.each do |message| %>
7
+ <li><%%= message %></li>
8
+ <%% end %>
9
+ </ul>
10
+ <%% end %>
11
+
12
+ <p>
13
+ <%%= f.label :event_pattern, "Event pattern (\"rental.\" for a whole domain, or one exact event name)" %><br>
14
+ <%%= f.text_field :event_pattern %>
15
+ </p>
16
+ <p>
17
+ <%%= f.label :url, "URL (http:// or https://)" %><br>
18
+ <%%= f.text_field :url %>
19
+ </p>
20
+ <p>
21
+ <%%= f.label :active %>
22
+ <%%= f.check_box :active %>
23
+ </p>
24
+
25
+ <%%= f.submit "Create endpoint" %>
26
+ <%% end %>
27
+
28
+ <p>A signing secret is generated for you and shown on the list.</p>
@@ -0,0 +1,29 @@
1
+ /* Loam admin console — shared styles (extracted from the layout, L-402). */
2
+ body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; color: #1c1c1a; line-height: 1.5; }
3
+ h1 { font-size: 1.55rem; margin: 0 0 0.75rem; }
4
+ h2 { font-size: 1.2rem; margin: 1.25rem 0 0.5rem; }
5
+ a { color: #1a5a9a; }
6
+
7
+ table { border-collapse: collapse; margin: 0.5rem 0 1rem; }
8
+ th, td { border: 1px solid #d5d5cf; padding: 0.4rem 0.8rem; text-align: left; vertical-align: top; }
9
+ th { background: #f5f4f0; font-weight: 600; }
10
+
11
+ nav { margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e4e2db; }
12
+ nav a { margin-right: 1rem; }
13
+ nav form { display: inline; }
14
+ .context { color: #555; font-size: 0.9rem; margin-bottom: 1rem; }
15
+
16
+ form p { margin: 0.5rem 0; }
17
+ input, select, textarea { padding: 0.3rem 0.4rem; font: inherit; }
18
+ button, input[type="submit"] { padding: 0.35rem 0.8rem; font: inherit; cursor: pointer; }
19
+
20
+ /* Flash messages — controllers set notice:/alert:, the layout renders them. */
21
+ .flash { padding: 0.6rem 0.9rem; border-radius: 6px; margin: 0 0 1rem; max-width: 60rem; }
22
+ .flash-notice { background: #e6f4ea; border: 1px solid #b7dfc4; color: #1e5631; }
23
+ .flash-alert { background: #fdecec; border: 1px solid #f2b8b8; color: #8a1c1c; }
24
+
25
+ .conflict { border: 2px solid #b00; padding: 0.75rem; margin-bottom: 1rem; }
26
+ .lock-banner { background: #fffbe6; border: 1px solid #ece2a6; padding: 0.5rem; margin-bottom: 1rem; }
27
+
28
+ code { background: #f3f0e9; padding: 0.05em 0.3em; border-radius: 3px; }
29
+ small { color: #666; }
@@ -0,0 +1,90 @@
1
+ module Api
2
+ # Base class for every JSON endpoint. Authentication is a bearer token:
3
+ #
4
+ # curl -H "Authorization: Bearer <token>" http://localhost:3000/api/equipment
5
+ #
6
+ # A token identifies one user in one tenant, and `Loam::ApiToken.authenticate`
7
+ # establishes both in Loam::Current before any action runs. From that point
8
+ # on this is ordinary tenant-scoped Loam code: every query is filtered, every
9
+ # write is audited, every policy applies — the API is not a side door.
10
+ #
11
+ # Finding a token is the one lookup that cannot start from a tenant (the
12
+ # token is what reveals the tenant), which is why it lives in the gem rather
13
+ # than here: host app code must never reach across tenants.
14
+ class BaseController < ActionController::API
15
+ before_action :authenticate_api_token!
16
+
17
+ rescue_from Loam::NotAuthorizedError do
18
+ render json: { error: "forbidden" }, status: :forbidden
19
+ end
20
+
21
+ rescue_from ActiveRecord::RecordNotFound do
22
+ render json: { error: "not_found" }, status: :not_found
23
+ end
24
+
25
+ rescue_from Loam::UnknownCustomFieldError do |error|
26
+ render json: { error: error.message }, status: :unprocessable_entity
27
+ end
28
+
29
+ private
30
+
31
+ def authenticate_api_token!
32
+ @api_token = Loam::ApiToken.authenticate(bearer_token)
33
+
34
+ render json: { error: "unauthorized" }, status: :unauthorized unless @api_token
35
+ end
36
+
37
+ def bearer_token
38
+ request.headers["Authorization"].to_s[/\ABearer (.+)\z/, 1]
39
+ end
40
+
41
+ def current_tenant = Loam::Current.tenant
42
+ def current_actor = Loam::Current.actor
43
+
44
+ def policy_for(record)
45
+ Loam::Policy.for(record)
46
+ end
47
+
48
+ def authorize!(policy, action)
49
+ raise Loam::NotAuthorizedError unless policy.public_send(action)
50
+ end
51
+
52
+ # The JSON shape of an entity: its columns, custom fields included (they
53
+ # live in the `custom_fields` column that every generated entity carries).
54
+ #
55
+ # Encrypted fields (Loam::Encryptable) are returned DECRYPTED — the caller is
56
+ # authenticated, tenant-scoped and policy-gated, exactly like the admin show
57
+ # screen — and their blind-index `<field>_hash` column is dropped, so the
58
+ # equality-leaking hash never goes over the wire. `record.attributes` alone
59
+ # would emit the raw ciphertext plus the hash.
60
+ def entity_json(record, enrichments: nil)
61
+ json = record.attributes
62
+
63
+ if record.class.respond_to?(:loam_encrypted_attributes)
64
+ record.class.loam_encrypted_attributes.each do |name|
65
+ json[name] = record.public_send(name)
66
+ json.delete("#{name}_hash")
67
+ end
68
+ end
69
+
70
+ # Computed cross-module blocks (Loam::Enrichers), under a separate key so
71
+ # they're never confused with the record's own columns. An index passes the
72
+ # batched result (avoiding N+1); a single show computes it here.
73
+ enrichments = Loam::Enrichers.enrich(record) if enrichments.nil?
74
+ json["enrichments"] = enrichments if enrichments.present?
75
+ json
76
+ end
77
+
78
+ # Same field-level enforcement as the admin screens: only a definition the
79
+ # actor's role may write is assigned. Custom fields ride at the top level
80
+ # of the request body, beside the entity key.
81
+ def assign_custom_fields!(record, params, policy)
82
+ submitted = params[:custom_fields]
83
+ return unless submitted
84
+
85
+ submitted.each do |name, value|
86
+ record.set_custom_field(name, value) if policy.custom_field_writable?(name)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,79 @@
1
+ require "test_helper"
2
+
3
+ # Structural guardrails. These are not feature tests — they enforce Loam's
4
+ # invariants on the codebase itself. If one of these fails, an agent (or a
5
+ # human) stepped outside the conventions.
6
+ class LoamGuardrailsTest < ActiveSupport::TestCase
7
+ # Models that are legitimately not tenant-scoped. Loam::Config holds both
8
+ # global (tenant_id NULL) and per-tenant override rows, so tenancy is a
9
+ # nullable column and resolution lives in vetted gem code (Loam::Configs).
10
+ # Loam::MfaCredential belongs to the person, who spans tenants — MFA is
11
+ # verified at login before any tenant is chosen.
12
+ TENANCY_ALLOWLIST = %w[ApplicationRecord User Loam::Tenant Loam::Config Loam::MfaCredential Loam::AuthAttempt].freeze
13
+
14
+ # Rails' own engine models (storage, rich text, jobs) are framework
15
+ # plumbing, not business data — they are out of scope for tenant linting.
16
+ FRAMEWORK_NAMESPACES = %w[ActiveStorage:: ActionText:: ActionMailbox:: SolidQueue:: SolidCache:: SolidCable::].freeze
17
+
18
+ test "every app model is tenant-scoped (inherits Loam::TenantRecord)" do
19
+ Rails.application.eager_load!
20
+
21
+ offenders = ActiveRecord::Base.descendants.reject do |model|
22
+ model.abstract_class? ||
23
+ model.name.nil? ||
24
+ TENANCY_ALLOWLIST.include?(model.name) ||
25
+ FRAMEWORK_NAMESPACES.any? { |ns| model.name.start_with?(ns) } ||
26
+ model <= Loam::TenantRecord
27
+ end
28
+
29
+ assert_empty offenders,
30
+ "These models are NOT tenant-scoped: #{offenders.map(&:name).join(', ')}. " \
31
+ "Business models must inherit Loam::TenantRecord (use `rails g loam:entity`). " \
32
+ "If a model is intentionally global, add it to TENANCY_ALLOWLIST here — in review, on purpose."
33
+ end
34
+
35
+ test "touching a tenant-scoped model with no tenant context raises" do
36
+ Loam::Current.reset
37
+
38
+ assert_raises(Loam::MissingTenantError) { Loam::AuditRecord.count }
39
+ assert_raises(Loam::MissingTenantError) { Loam::Membership.first }
40
+ end
41
+
42
+ test "nobody uses .unscoped outside vetted framework code" do
43
+ offenders = Dir[Rails.root.join("app/**/*.rb")].select do |file|
44
+ File.read(file).match?(/\bunscoped\b/)
45
+ end
46
+
47
+ assert_empty offenders,
48
+ ".unscoped found in: #{offenders.join(', ')}. It bypasses tenant isolation — remove it."
49
+ end
50
+
51
+ # Agent harnesses read AGENTS.md into a context window and truncate what
52
+ # doesn't fit — silently. An overweight file doesn't warn, it just loses its
53
+ # tail, and the tail is where the invariants live.
54
+ AGENTS_MD_BUDGET_BYTES = 32_768
55
+
56
+ test "AGENTS.md stays inside its byte budget" do
57
+ path = Rails.root.join("AGENTS.md")
58
+
59
+ assert path.exist?, "AGENTS.md is missing from #{Rails.root} — it is the contract AI agents work against."
60
+ assert path.size <= AGENTS_MD_BUDGET_BYTES,
61
+ "AGENTS.md is #{path.size} bytes, over the #{AGENTS_MD_BUDGET_BYTES}-byte budget. " \
62
+ "Agent harnesses truncate instruction files without warning, so the tail — invariants, " \
63
+ "definition of done — would silently stop being read. Cut prose or move detail into " \
64
+ "docs/ and link it, rather than raising the budget."
65
+ end
66
+
67
+ test "every Loam::FieldDefinition entity_type resolves to a class that uses Loam::CustomFields" do
68
+ Rails.application.eager_load!
69
+
70
+ offenders = Loam::FieldDefinition.unscoped.distinct.pluck(:entity_type).reject do |entity_type|
71
+ klass = entity_type.safe_constantize
72
+ klass && klass.include?(Loam::CustomFields)
73
+ end
74
+
75
+ assert_empty offenders,
76
+ "These Loam::FieldDefinition entity_type values don't resolve to a class that " \
77
+ "`include Loam::CustomFields`: #{offenders.join(', ')}. Likely a typo when the field was created."
78
+ end
79
+ end
@@ -0,0 +1,25 @@
1
+ # Runs a CSV import in the background, advancing a Loam::ProgressJob so the admin
2
+ # watches it live (L-915). Carries tenant + actor explicitly, like every Loam job.
3
+ # The per-row summary (created/updated/failed + error rows) is stored on the
4
+ # ProgressJob's `result` for the summary screen.
5
+ class ImportJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform(entity_type:, csv:, mapping:, match_key:, tenant_id:, actor_id:)
9
+ tenant = Loam::Tenant.find(tenant_id)
10
+ actor = User.find_by(id: actor_id)
11
+ model = Loam::Import.allowed_model(entity_type)
12
+
13
+ Loam.as_tenant(tenant, actor: actor) do
14
+ total = [ CSV.parse(csv).size - 1, 0 ].max
15
+ progress = Loam::Progress.start(name: "Import #{entity_type}", total: total)
16
+ begin
17
+ result = Loam::Import.run(csv, model: model, mapping: mapping, actor: actor, match_key: match_key, progress: progress)
18
+ progress.update_columns(result: result.to_h)
19
+ progress.complete!
20
+ rescue StandardError => error
21
+ progress.fail!(error.message)
22
+ end
23
+ end
24
+ end
25
+ end