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
+ class CreateLoamSearchTokens < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_search_tokens<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :searchable_type, null: false # the entity class name (e.g. "Gadget")
6
+ t.<%= loam_key_column_type %> :searchable_id<%= loam_key_limit_option %>, null: false # the record it indexes
7
+ t.string :token, null: false # one normalized word
8
+ end
9
+ # The match subquery filters by (tenant, type, token); index maintenance
10
+ # deletes by (tenant, type, record). One index each — no timestamps: these
11
+ # are plumbing rows written with insert_all/delete_all.
12
+ #
13
+ # Shipped even though the default LikeDriver never uses it, so switching to
14
+ # Loam::Search::TokenDriver is a one-line initializer change, not a migration.
15
+ add_index :loam_search_tokens, %i[tenant_id searchable_type token]
16
+ add_index :loam_search_tokens, %i[tenant_id searchable_type searchable_id]
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ class CreateLoamSsoProviders < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_sso_providers<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :name, null: false
6
+ t.string :protocol, null: false, default: "oidc" # "oidc" shipped; "saml" is a seam
7
+ t.string :issuer # OIDC discovery base (.well-known)
8
+ t.string :client_id
9
+ t.text :client_secret # ENCRYPTED at rest (tenant-scoped key)
10
+ t.string :domain, null: false # email domain for home-realm discovery
11
+ t.string :jit_role, null: false, default: "employee" # role for JIT-provisioned users
12
+ t.json :group_role_map, null: false, default: {} # IdP group -> Loam role (first match wins)
13
+ t.boolean :active, null: false, default: true
14
+ t.timestamps
15
+ end
16
+ # HRD resolves a provider by email domain, cross-tenant, before login — so the
17
+ # domain is globally unique (one owning IdP per domain).
18
+ add_index :loam_sso_providers, :domain, unique: true
19
+ add_index :loam_sso_providers, %i[tenant_id active]
20
+
21
+ # The durable link between an external identity (provider + subject) and a
22
+ # local User — the primary lookup on callback (email can change at the IdP,
23
+ # `sub` does not).
24
+ create_table :loam_sso_identities<%= loam_id_option %> do |t|
25
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
26
+ t.references :sso_provider, null: false, foreign_key: { to_table: :loam_sso_providers }<%= loam_type_option %>
27
+ t.<%= loam_key_column_type %> :user_id<%= loam_key_limit_option %>, null: false # the local User this identity belongs to
28
+ t.string :sub, null: false # the IdP's stable subject identifier
29
+ t.timestamps
30
+ end
31
+ add_index :loam_sso_identities, %i[sso_provider_id sub], unique: true
32
+ add_index :loam_sso_identities, :user_id
33
+ end
34
+ end
@@ -0,0 +1,9 @@
1
+ class CreateLoamTenants < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_tenants<%= loam_id_option %> do |t|
4
+ t.string :name, null: false
5
+ t.string :slug, null: false, index: { unique: true }
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ class CreateLoamTranslations < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_translations<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :translatable_type, null: false
6
+ t.<%= loam_key_column_type %> :translatable_id<%= loam_key_limit_option %>, null: false
7
+ t.string :locale, null: false # "en", "de", "pl"
8
+ t.string :field, null: false # the translated attribute
9
+ t.text :value # the per-locale content
10
+ t.timestamps
11
+ end
12
+ add_index :loam_translations, %i[translatable_type translatable_id locale field],
13
+ unique: true, name: "index_loam_translations_unique"
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ class CreateLoamWebhookEndpoints < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :loam_webhook_endpoints<%= loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :loam_tenants }<%= loam_type_option %>
5
+ t.string :url, null: false
6
+ t.string :event_pattern, null: false
7
+ t.string :secret, null: false
8
+ t.boolean :active, null: false, default: true
9
+ t.timestamps
10
+ end
11
+ add_index :loam_webhook_endpoints, %i[tenant_id active]
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ class CreateUsers < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :users<%= loam_id_option %> do |t|
4
+ t.string :name, null: false
5
+ t.string :email, null: false
6
+ t.string :password_digest, null: false
7
+ t.timestamps
8
+ end
9
+ # Email is the login, so uniqueness is enforced by the database, not only
10
+ # by the model.
11
+ add_index :users, :email, unique: true
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # Minimal actor model. Deliberately NOT tenant-scoped: a user can belong to
2
+ # many tenants via Loam::Membership (role is per tenant). Swap in your real
3
+ # auth (Devise, OmniAuth, SSO) later — Loam only needs an `id` and, for the
4
+ # bundled admin login, something that answers `authenticate_by`.
5
+ class User < ApplicationRecord
6
+ # Loam's foreign keys point at this table, so its key has to be generated
7
+ # the same way theirs are when the app does not use integer keys.
8
+ include Loam::GeneratedKey
9
+
10
+ has_secure_password
11
+
12
+ has_many :loam_memberships, class_name: "Loam::Membership"
13
+
14
+ validates :name, presence: true
15
+ validates :email, presence: true, uniqueness: true
16
+
17
+ # Email is the login, so it must mean the same thing however it was typed.
18
+ # `normalizes` applies on write AND to finders, so User.authenticate_by and
19
+ # find_by match a mixed-case address without callers downcasing anything.
20
+ normalizes :email, with: ->(email) { email.to_s.strip.downcase }
21
+ end
@@ -0,0 +1,101 @@
1
+ module Loam
2
+ module Generators
3
+ # Shared primary-key shaping for `loam:install` and `loam:entity`.
4
+ #
5
+ # Loam's migrations used to spell every key `bigint` — implicitly in
6
+ # `create_table`, and explicitly in `t.references` and the polymorphic
7
+ # `*_id` columns. In an app whose own tables use string or uuid keys the
8
+ # foreign keys then do not line up with the tables they point at, and every
9
+ # generated migration has to be hand-edited after the generator runs.
10
+ #
11
+ # The key type is taken from, in order: an explicit --primary-key-type, the
12
+ # host app's own `config.generators` setting (so an app that already told
13
+ # Rails what its keys look like does not have to tell Loam separately), and
14
+ # bigint as the Rails default.
15
+ #
16
+ # The templates ask for the *rendered fragment* rather than the type, so
17
+ # bigint keeps emitting exactly the migrations it emitted before — no `id:`
18
+ # and no `type:` — and only a non-default key type adds anything.
19
+ module PrimaryKeyOptions
20
+ SUPPORTED = %w[bigint uuid string].freeze
21
+
22
+ # varchar(36) is the usual shape for a UUID stored as a string, and it is
23
+ # the case in the report that prompted this. An app that stores them some
24
+ # other width passes --key-limit.
25
+ DEFAULT_STRING_LIMIT = 36
26
+
27
+ def self.included(base)
28
+ base.class_option :primary_key_type, type: :string, default: nil,
29
+ desc: "Key type for the generated tables: #{SUPPORTED.join(', ')}. " \
30
+ "Defaults to the app's config.generators primary_key_type, then bigint."
31
+ base.class_option :key_limit, type: :numeric, default: nil,
32
+ desc: "Column limit for a string key (default #{DEFAULT_STRING_LIMIT}). Ignored for bigint and uuid."
33
+ end
34
+
35
+ private
36
+
37
+ def loam_key_type
38
+ @loam_key_type ||= begin
39
+ requested = (options[:primary_key_type] || host_app_primary_key_type).to_s
40
+ requested = "bigint" if requested.empty?
41
+
42
+ unless SUPPORTED.include?(requested)
43
+ raise Thor::Error,
44
+ "Unsupported --primary-key-type #{requested.inspect}. Loam generates #{SUPPORTED.join(', ')}. " \
45
+ "A key type Loam does not know how to size would produce migrations that look right and " \
46
+ "do not line up with your tables."
47
+ end
48
+
49
+ requested.to_sym
50
+ end
51
+ end
52
+
53
+ # What the host app already told Rails its keys look like. Absent outside
54
+ # a booted application (the generator specs), which is the bigint case.
55
+ def host_app_primary_key_type
56
+ return nil unless defined?(Rails) && Rails.application
57
+
58
+ Rails.application.config.generators.options.dig(:active_record, :primary_key_type)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def loam_key_limit
64
+ return options[:key_limit].to_i if options[:key_limit]
65
+
66
+ loam_key_type == :string ? DEFAULT_STRING_LIMIT : nil
67
+ end
68
+
69
+ # Appended to `create_table :x` — "" for bigint, so the default path is
70
+ # byte-for-byte what it was.
71
+ def loam_id_option
72
+ return "" if loam_key_type == :bigint
73
+
74
+ "#{loam_key_fragment.sub('type:', 'id:')}"
75
+ end
76
+
77
+ # Appended inside `t.references :x, ...`.
78
+ def loam_type_option
79
+ return "" if loam_key_type == :bigint
80
+
81
+ loam_key_fragment
82
+ end
83
+
84
+ # The bare column type for a polymorphic or un-constrained `*_id`, which
85
+ # cannot use t.references.
86
+ def loam_key_column_type
87
+ loam_key_type
88
+ end
89
+
90
+ def loam_key_limit_option
91
+ loam_key_limit ? ", limit: #{loam_key_limit}" : ""
92
+ end
93
+
94
+ def loam_key_fragment
95
+ fragment = ", type: :#{loam_key_type}"
96
+ fragment += ", limit: #{loam_key_limit}" if loam_key_limit
97
+ fragment
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,27 @@
1
+ module Loam
2
+ # Files on a record, via ActiveStorage:
3
+ #
4
+ # equipment.files.attach(io: File.open(path), filename: "manual.pdf")
5
+ # equipment.files.each { |file| url_for(file) }
6
+ #
7
+ # KNOWN BOUNDARY: ActiveStorage blobs live in global tables that Loam does
8
+ # NOT tenant-scope. The association is scoped (a record only ever lists its
9
+ # own files, and the record is tenant-scoped), but a signed blob URL is a
10
+ # bearer capability: whoever holds the link can fetch the file, with no
11
+ # tenant check. Gate access at the record — through its policy — and treat
12
+ # attachment URLs as secrets rather than as addresses.
13
+ module Attachable
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ unless respond_to?(:has_many_attached)
18
+ raise Loam::Error,
19
+ "#{name} includes Loam::Attachable but ActiveStorage is not available. " \
20
+ "Run `bin/rails active_storage:install && bin/rails db:migrate`, or remove the include " \
21
+ "(this app was probably generated with --skip-active-storage)."
22
+ end
23
+
24
+ has_many_attached :files
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ module Loam
2
+ # Audit-by-default. Included in every generated entity: create/update/destroy
3
+ # writes a Loam::AuditRecord tagged with tenant + actor + changeset. "Who
4
+ # changed the excavator's price, and when" is answered structurally, not by
5
+ # remembering to log.
6
+ module Auditable
7
+ extend ActiveSupport::Concern
8
+
9
+ IGNORED_ATTRIBUTES = %w[updated_at created_at].freeze
10
+
11
+ included do
12
+ after_create { loam_audit("create") }
13
+ after_update { loam_audit(loam_audit_action) if loam_audit_changes.any? }
14
+ after_destroy { loam_audit("destroy") }
15
+ end
16
+
17
+ private
18
+
19
+ # Relabel the audit that the save inside the block writes. Loam::SoftDeletable
20
+ # uses this to record a soft-delete (an UPDATE) as "soft_delete"/"restore"
21
+ # rather than the "update" it would otherwise be — one audit path, reused,
22
+ # not a second one to keep in sync.
23
+ def loam_audit_as(action)
24
+ previous = @loam_audit_action
25
+ @loam_audit_action = action
26
+ yield
27
+ ensure
28
+ @loam_audit_action = previous
29
+ end
30
+
31
+ def loam_audit_action
32
+ @loam_audit_action || "update"
33
+ end
34
+
35
+ def loam_audit(action)
36
+ Loam::AuditRecord.create!(
37
+ auditable_type: self.class.name,
38
+ auditable_id: id,
39
+ action: action,
40
+ actor_id: Loam::Current.actor&.id,
41
+ changeset: action == "destroy" ? {} : loam_audit_changes
42
+ )
43
+ end
44
+
45
+ def loam_audit_changes
46
+ changes = saved_changes.except(*IGNORED_ATTRIBUTES)
47
+ loam_redact_encrypted(changes)
48
+ end
49
+
50
+ # The audit trail must record the FACT that an encrypted field changed, never
51
+ # its value — neither the plaintext nor the ciphertext (a ciphertext still
52
+ # leaks length and, over time, correlations). So an encrypted column's change
53
+ # becomes "[encrypted]", and its blind-index sibling is dropped entirely.
54
+ # A no-op for models without Loam::Encryptable.
55
+ def loam_redact_encrypted(changes)
56
+ return changes unless self.class.respond_to?(:loam_encrypted_attributes)
57
+
58
+ encrypted = self.class.loam_encrypted_attributes.map(&:to_s)
59
+ hash_columns = self.class.loam_searchable_encrypted_attributes.map { |name| "#{name}_hash" }
60
+
61
+ changes.each_with_object({}) do |(column, values), redacted|
62
+ next if hash_columns.include?(column)
63
+ redacted[column] = encrypted.include?(column) ? "[encrypted]" : values
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,83 @@
1
+ module Loam
2
+ # Rate-limiting + lockout for authentication, to blunt online brute-force of
3
+ # passwords and (especially) 6-digit TOTP codes. A DB-backed counter keyed by
4
+ # the submitted identifier: after N failures within a window, the identifier is
5
+ # locked; a success clears the counter.
6
+ #
7
+ # Loam::AuthThrottle.locked?(email) # refuse if true
8
+ # Loam::AuthThrottle.record_failure(email, kind: "password", ip: request.ip)
9
+ # Loam::AuthThrottle.clear(email) # on success
10
+ #
11
+ # PER-IDENTIFIER is the primary defense (an attacker targets one account /
12
+ # code). A per-ip throttle to blunt spraying across accounts is a clean
13
+ # addition behind the same store (record_failure takes an ip) but not wired by
14
+ # default — noted as a roadmap knob. Rack::Attack is the PRODUCTION tool
15
+ # (needs a cache store + a gem); this DB counter is the portable,
16
+ # single-process-correct prototype.
17
+ #
18
+ # Thresholds come from Loam::Configs (per-tenant-overridable, but read globally
19
+ # here at the auth layer with sane defaults):
20
+ # security.max_auth_attempts (default 10)
21
+ # security.auth_window_minutes (default 15) — failures counted in this window
22
+ # security.auth_lockout_minutes(default 15) — how long "try again in N" reports
23
+ module AuthThrottle
24
+ module_function
25
+
26
+ def max_attempts
27
+ Loam::Configs.get("security.max_auth_attempts", default: 10).to_i
28
+ end
29
+
30
+ def window
31
+ Loam::Configs.get("security.auth_window_minutes", default: 15).to_i.minutes
32
+ end
33
+
34
+ def lockout
35
+ Loam::Configs.get("security.auth_lockout_minutes", default: 15).to_i.minutes
36
+ end
37
+
38
+ # Log a failed attempt. Recorded for ANY submitted identifier — existing or
39
+ # not — so a lockout can never become an account-existence oracle.
40
+ def record_failure(identifier, kind:, ip: nil)
41
+ identifier = normalize(identifier)
42
+ return if identifier.blank?
43
+
44
+ Loam::AuthAttempt.create!(identifier: identifier, kind: kind.to_s, ip: ip)
45
+ end
46
+
47
+ # Locked if there are >= max failures within the window. Old attempts age out
48
+ # of the window automatically (the window query IS the expiry — no reaper).
49
+ def locked?(identifier, kind: nil)
50
+ recent_failures(identifier, kind: kind) >= max_attempts
51
+ end
52
+
53
+ def recent_failures(identifier, kind: nil)
54
+ scope = attempts(identifier).where("created_at > ?", window.ago)
55
+ scope = scope.where(kind: kind.to_s) if kind
56
+ scope.count
57
+ end
58
+
59
+ # Reset the counter — call on a SUCCESSFUL auth so a legitimate user who
60
+ # eventually gets in isn't left throttled.
61
+ def clear(identifier)
62
+ attempts(identifier).delete_all
63
+ end
64
+
65
+ # Seconds until the identifier unlocks (for the "try again in N" message),
66
+ # or 0 when not locked.
67
+ def remaining_lockout(identifier)
68
+ last = attempts(identifier).maximum(:created_at)
69
+ return 0 unless last
70
+
71
+ seconds = (last + [ window, lockout ].max - Time.current).to_i
72
+ seconds.positive? ? seconds : 0
73
+ end
74
+
75
+ def attempts(identifier)
76
+ Loam::AuthAttempt.where(identifier: normalize(identifier))
77
+ end
78
+
79
+ def normalize(identifier)
80
+ identifier.to_s.strip.downcase
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,23 @@
1
+ module Loam
2
+ # RFC 4648 Base32 — the encoding authenticator apps expect for a TOTP secret.
3
+ # Ruby ships Base64 but not Base32, and this is ~15 lines, so no dependency.
4
+ # No padding on encode (authenticators don't want it); decode ignores case,
5
+ # spaces and `=` padding so a secret pasted from anywhere still works.
6
+ module Base32
7
+ ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".freeze
8
+
9
+ def self.encode(bytes)
10
+ bits = bytes.bytes.map { |byte| byte.to_s(2).rjust(8, "0") }.join
11
+ # Pad the bit string up to a multiple of 5, then map each 5 bits to a char.
12
+ bits += "0" * ((5 - bits.length % 5) % 5)
13
+ bits.scan(/.{5}/).map { |chunk| ALPHABET[chunk.to_i(2)] }.join
14
+ end
15
+
16
+ def self.decode(string)
17
+ clean = string.to_s.upcase.gsub(/[^A-Z2-7]/, "")
18
+ bits = clean.each_char.map { |char| ALPHABET.index(char).to_s(2).rjust(5, "0") }.join
19
+ # Only whole bytes are data; the trailing <8 bits are encoding padding.
20
+ bits.scan(/.{8}/).map { |byte| byte.to_i(2).chr }.join.b
21
+ end
22
+ end
23
+ end
data/lib/loam/bulk.rb ADDED
@@ -0,0 +1,59 @@
1
+ module Loam
2
+ # Datatable bulk actions over selected records — policy-checked PER record and
3
+ # tenant-scoped. The ids are resolved THROUGH the tenant scope
4
+ # (`model.where(id: ids)`), so a forged cross-tenant id is simply not found —
5
+ # a bulk op can never reach another tenant's rows.
6
+ #
7
+ # Loam::Bulk.soft_delete(Equipment, params[:ids])
8
+ # Loam::Bulk.set_field(Equipment, params[:ids], field: "status", value: "retired")
9
+ #
10
+ # Returns the number of records actually affected (a record the actor may not
11
+ # touch, or an id from another tenant, is silently skipped).
12
+ module Bulk
13
+ module_function
14
+
15
+ def soft_delete(model, ids)
16
+ each_permitted(model, ids) do |record, policy|
17
+ next unless policy.destroy?
18
+
19
+ record.soft_delete!
20
+ true
21
+ end
22
+ end
23
+
24
+ def set_field(model, ids, field:, value:)
25
+ field = field.to_s
26
+ each_permitted(model, ids) do |record, policy|
27
+ next unless policy.update? && policy.writable?(field) && writable_column?(model, field)
28
+
29
+ record.update!(field => value)
30
+ true
31
+ end
32
+ end
33
+
34
+ # A relation for "export selected" — tenant-scoped, so it composes with
35
+ # Loam::Export.csv and can't leak another tenant's rows.
36
+ def selected(model, ids)
37
+ model.where(id: Array(ids))
38
+ end
39
+
40
+ def each_permitted(model, ids)
41
+ count = 0
42
+ selected(model, ids).find_each do |record|
43
+ count += 1 if yield(record, Loam::Policy.for(record))
44
+ end
45
+ count
46
+ end
47
+
48
+ def writable_column?(model, field)
49
+ model.column_names.include?(field) && Loam::Import::PLUMBING.exclude?(field) && !workflow_column?(model, field)
50
+ end
51
+
52
+ # A workflow status column may ONLY change through a transition (role-gated).
53
+ # Skip it here so a bulk set-field can't self-"approve" (the model guard is
54
+ # the backstop; this keeps the bulk op a clean no-op rather than an error).
55
+ def workflow_column?(model, field)
56
+ model.respond_to?(:loam_workflow) && model.loam_workflow&.column == field
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,92 @@
1
+ module Loam
2
+ module BusinessRules
3
+ # The typed action vocabulary — a FIXED, safe set the engine dispatches. No
4
+ # arbitrary code: an action is a json descriptor with a `type` and known
5
+ # keys. `call_webhook` is deferred (emit_event → a webhook endpoint already
6
+ # covers it).
7
+ module Actions
8
+ PLUMBING = %w[id tenant_id lock_version deleted_at created_at updated_at].freeze
9
+
10
+ # Run one action against the record that triggered the rule. Returns a
11
+ # short label of what it did (for the execution log). `:veto` is the
12
+ # block_transition signal (see Loam::BusinessRules.veto?).
13
+ def self.run(action, record)
14
+ case action["type"].to_s
15
+ when "notify" then run_notify(action, record)
16
+ when "emit_event" then run_emit_event(action)
17
+ when "set_field" then run_set_field(action, record)
18
+ when "block_transition" then :veto
19
+ else raise ArgumentError, "unknown action type #{action["type"].inspect}"
20
+ end
21
+ end
22
+
23
+ def self.run_notify(action, _record)
24
+ title = action["title"].to_s
25
+ body = action["body"]
26
+ if action["role"].present?
27
+ Loam::Notifications.notify_role(action["role"], title: title, body: body)
28
+ elsif action["user_id"].present?
29
+ user = User.find_by(id: action["user_id"])
30
+ Loam::Notifications.notify(user, title: title, body: body) if user
31
+ end
32
+ "notify"
33
+ end
34
+
35
+ # Publish validates the name format (raising InvalidEventNameError), so a
36
+ # malformed event name is caught by the engine's per-rule isolation.
37
+ def self.run_emit_event(action)
38
+ Loam::Events.publish(action["name"], (action["payload"] || {}).symbolize_keys)
39
+ "emit_event(#{action["name"]})"
40
+ end
41
+
42
+ # Assign a whitelisted attribute or custom field on the TRIGGERING record.
43
+ # Refused: the workflow status column (would bypass the transition gate —
44
+ # use a transition), and plumbing columns. The change is saved as the
45
+ # event's actor, and the rule is identifiable via the execution log.
46
+ def self.run_set_field(action, record)
47
+ field = action["field"].to_s
48
+ refuse_workflow_column!(record, field)
49
+ refuse_credential_column!(field)
50
+ refuse_cross_tenant!(record)
51
+
52
+ if writable_column?(record.class, field)
53
+ record.update!(field => action["value"])
54
+ elsif Condition.custom_field?(record, field)
55
+ record.set_custom_field(field, action["value"])
56
+ record.save!
57
+ else
58
+ raise ArgumentError, "set_field: #{field.inspect} is not a writable column or custom field"
59
+ end
60
+ "set_field(#{field})"
61
+ end
62
+
63
+ def self.writable_column?(klass, field)
64
+ klass.column_names.include?(field) && PLUMBING.exclude?(field)
65
+ end
66
+
67
+ def self.refuse_workflow_column!(record, field)
68
+ return unless record.class.respond_to?(:loam_workflow) && record.class.loam_workflow&.column == field
69
+
70
+ raise ArgumentError, "set_field must not write the workflow column #{field.inspect} — use a transition"
71
+ end
72
+
73
+ # A business rule has no business setting credentials, even on a
74
+ # tenant-scoped record: never a password column, a *_digest, or email.
75
+ def self.refuse_credential_column!(field)
76
+ normalized = field.downcase
77
+ return unless normalized.include?("password") || normalized.end_with?("_digest") || normalized == "email"
78
+
79
+ raise ArgumentError, "set_field must not write the credential column #{field.inspect}"
80
+ end
81
+
82
+ # Belt-and-suspenders: subject_for only ever loads a current-tenant record,
83
+ # but never write one whose tenant is not the tenant in context.
84
+ def self.refuse_cross_tenant!(record)
85
+ return unless record.respond_to?(:tenant_id)
86
+ return if record.tenant_id == Loam.tenant!.id
87
+
88
+ raise ArgumentError, "set_field refuses a record outside the current tenant"
89
+ end
90
+ end
91
+ end
92
+ end