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,42 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Loam
5
+ module Sso
6
+ # The one place SSO talks to the network — kept tiny and isolated so the rest
7
+ # of the flow is pure. Only Loam::Sso::OidcProvider uses it, and the test
8
+ # suite injects a FakeProvider instead, so this is never exercised offline.
9
+ module HttpClient
10
+ module_function
11
+
12
+ def get_json(url, bearer: nil)
13
+ uri = URI(url)
14
+ request = Net::HTTP::Get.new(uri)
15
+ request["Authorization"] = "Bearer #{bearer}" if bearer
16
+ parse(perform(uri, request))
17
+ end
18
+
19
+ def post_form(url, params)
20
+ uri = URI(url)
21
+ request = Net::HTTP::Post.new(uri)
22
+ request.set_form_data(params)
23
+ request["Accept"] = "application/json"
24
+ parse(perform(uri, request))
25
+ end
26
+
27
+ def perform(uri, request)
28
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
29
+ http.request(request)
30
+ end
31
+ end
32
+
33
+ def parse(response)
34
+ unless response.is_a?(Net::HTTPSuccess)
35
+ raise Loam::Sso::Error, "identity provider returned #{response.code}"
36
+ end
37
+
38
+ JSON.parse(response.body)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,64 @@
1
+ module Loam
2
+ module Sso
3
+ # The real OIDC Authorization Code provider. Discovers the issuer's endpoints
4
+ # from its .well-known document, sends the user to the authorization endpoint,
5
+ # then exchanges the returned code for an access token and reads the claims
6
+ # from the userinfo endpoint.
7
+ #
8
+ # Why userinfo rather than validating the id_token's JWT signature: these
9
+ # claims arrive over a server-to-server TLS channel WE opened, authenticated
10
+ # by the client_secret — the browser never touches them. Signature validation
11
+ # exists for a token you RECEIVE (the id_token, relayed via the browser);
12
+ # fetching userinfo over an authenticated back channel sidesteps hand-rolled
13
+ # JWKS verification for the prototype. Roadmap: full id_token + JWKS.
14
+ #
15
+ # NOT exercised by the test suite (a FakeProvider is injected via
16
+ # Loam::Sso.builder); live OIDC against a real IdP is verified manually.
17
+ class OidcProvider
18
+ SCOPE = "openid email profile".freeze
19
+
20
+ def initialize(record, redirect_uri:)
21
+ @record = record
22
+ @redirect_uri = redirect_uri
23
+ end
24
+
25
+ def authorization_url(state:, login_hint: nil)
26
+ params = {
27
+ response_type: "code",
28
+ client_id: @record.client_id,
29
+ redirect_uri: @redirect_uri,
30
+ scope: SCOPE,
31
+ state: state
32
+ }
33
+ params[:login_hint] = login_hint if login_hint.present?
34
+ "#{discovery.fetch('authorization_endpoint')}?#{params.to_query}"
35
+ end
36
+
37
+ def exchange(code:)
38
+ token = HttpClient.post_form(discovery.fetch("token_endpoint"), {
39
+ grant_type: "authorization_code",
40
+ code: code,
41
+ redirect_uri: @redirect_uri,
42
+ client_id: @record.client_id,
43
+ client_secret: @record.client_secret
44
+ })
45
+
46
+ info = HttpClient.get_json(discovery.fetch("userinfo_endpoint"), bearer: token.fetch("access_token"))
47
+
48
+ Claims.new(
49
+ sub: info["sub"],
50
+ email: info["email"],
51
+ email_verified: info["email_verified"],
52
+ name: info["name"],
53
+ groups: Array(info["groups"])
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def discovery
60
+ @discovery ||= HttpClient.get_json(File.join(@record.issuer, ".well-known/openid-configuration"))
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/loam/sso.rb ADDED
@@ -0,0 +1,129 @@
1
+ require "loam/sso/claims"
2
+ require "loam/sso/http_client"
3
+ require "loam/sso/oidc_provider"
4
+ require "loam/sso/fake_provider"
5
+
6
+ module Loam
7
+ # Single sign-on. SHIPPED: OIDC Authorization Code flow end-to-end —
8
+ # home-realm discovery by email domain, just-in-time user provisioning, IdP
9
+ # group -> role mapping, and account linking to an existing User. SEAMS
10
+ # (documented, not built): SAML (another protocol behind the same interface,
11
+ # raises NotImplementedError until implemented) and SCIM 2.0 provisioning
12
+ # (see docs/_foundation/overview.md). See Loam::SsoProvider for the per-tenant config.
13
+ #
14
+ # A protocol provider implements two methods:
15
+ # authorization_url(state:, login_hint: nil) -> the IdP URL to redirect to
16
+ # exchange(code:) -> Loam::Sso::Claims
17
+ #
18
+ # The provider is built through a swappable `builder` so tests and the demo
19
+ # inject a FakeProvider and NOTHING touches the network. Loam::Sso::OidcProvider
20
+ # is the real one; it is never constructed in the test suite.
21
+ module Sso
22
+ class Error < Loam::Error; end
23
+
24
+ # The identity provider did not assert a verified email. Linking or creating
25
+ # an account on an unverified email is an account-takeover vector, so the
26
+ # flow refuses it.
27
+ class UnverifiedEmailError < Error; end
28
+
29
+ # The verified email is on a domain this provider does not own. A provider
30
+ # only vouches for its OWN domain; letting it assert an email on another
31
+ # domain would let a tenant's IdP hijack a user of a different domain
32
+ # (cross-domain account takeover), so the flow refuses it.
33
+ class DomainMismatchError < Error; end
34
+
35
+ class << self
36
+ attr_writer :builder
37
+
38
+ def builder
39
+ @builder ||= method(:default_builder)
40
+ end
41
+
42
+ # Wrap a Loam::SsoProvider record in its protocol provider.
43
+ def build(record, redirect_uri:)
44
+ builder.call(record, redirect_uri)
45
+ end
46
+
47
+ def default_builder(record, redirect_uri)
48
+ case record.protocol
49
+ when "oidc"
50
+ OidcProvider.new(record, redirect_uri: redirect_uri)
51
+ when "saml"
52
+ raise NotImplementedError,
53
+ "SAML SSO is a documented seam — implement Loam::Sso::SamlProvider behind this interface."
54
+ else
55
+ raise Error, "Unsupported SSO protocol: #{record.protocol.inspect}"
56
+ end
57
+ end
58
+
59
+ # Home-realm discovery. THE blessed cross-tenant lookup for SSO (like
60
+ # Loam::Membership.tenants_for): "which tenant's IdP owns this email
61
+ # domain?" is asked at the sign-in page, before any tenant is chosen, so it
62
+ # reaches across tenants via `unscoped` — something host code must never do.
63
+ def provider_for(email:)
64
+ domain = email.to_s.split("@").last.to_s.strip.downcase
65
+ return nil if domain.blank?
66
+
67
+ Loam::SsoProvider.unscoped.where(domain: domain, active: true).first
68
+ end
69
+
70
+ # Resolve verified IdP claims to a signed-in User, in the provider's tenant.
71
+ # MUST be called inside Loam.as_tenant(provider.tenant). Order matters:
72
+ # match the durable (provider, sub) identity first, then an existing User by
73
+ # verified email (link), else just-in-time create — always with a tenant
74
+ # membership at the mapped role.
75
+ def provision(provider, claims)
76
+ raise UnverifiedEmailError, "the identity provider did not verify #{claims.email.inspect}" unless claims.email_verified?
77
+
78
+ # The provider only vouches for its own domain. Check this BEFORE any
79
+ # lookup, link, or create — an email on another domain is refused
80
+ # outright (no session, nothing touched).
81
+ email_domain = claims.email.to_s.split("@").last.to_s.downcase
82
+ unless email_domain.present? && email_domain == provider.domain.to_s.downcase
83
+ raise DomainMismatchError,
84
+ "verified email domain #{email_domain.inspect} is not this provider's domain #{provider.domain.inspect}"
85
+ end
86
+
87
+ # Resolve the user: the durable (provider, sub) identity first, then an
88
+ # existing User by verified email (link), else just-in-time create.
89
+ identity = Loam::SsoIdentity.find_by(sso_provider_id: provider.id, sub: claims.sub)
90
+ user = identity&.user || User.find_by(email: claims.email) || jit_create_user(claims)
91
+
92
+ # Re-map claims -> role on EVERY login (including a returning identity),
93
+ # so an IdP role change takes effect.
94
+ ensure_membership(provider, user, claims)
95
+ Loam::SsoIdentity.create!(user: user, sso_provider: provider, sub: claims.sub) unless identity
96
+ user
97
+ end
98
+
99
+ # IdP group -> role; first configured group that the user carries wins,
100
+ # otherwise the provider's default role.
101
+ def role_for(provider, claims)
102
+ map = provider.group_roles
103
+ match = Array(claims.groups).map(&:to_s).find { |group| map.key?(group) }
104
+ match ? map[match] : provider.jit_role
105
+ end
106
+
107
+ private
108
+
109
+ def jit_create_user(claims)
110
+ # SSO users authenticate at the IdP; the random password satisfies
111
+ # has_secure_password and is unguessable, never used to log in.
112
+ User.create!(
113
+ name: claims.name.presence || claims.email,
114
+ email: claims.email,
115
+ password: SecureRandom.hex(32)
116
+ )
117
+ end
118
+
119
+ # Re-map claims -> role on EVERY login, so an IdP role change takes effect
120
+ # (a promotion reflects, a removed group downgrades) rather than freezing
121
+ # at whatever the role was when the membership was first created.
122
+ def ensure_membership(provider, user, claims)
123
+ membership = Loam::Membership.find_or_initialize_by(user_id: user.id)
124
+ membership.role = role_for(provider, claims)
125
+ membership.save!
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,43 @@
1
+ module Loam
2
+ # A dependency-free observability seam (L-712). Wrap a unit of work in a span;
3
+ # by default it emits an `ActiveSupport::Notifications` event
4
+ # ("loam.span.<name>") carrying the attributes and duration, so ANY subscriber
5
+ # — an OpenTelemetry bridge, StatsD, a log line — picks it up with no hard
6
+ # dependency on Loam's side. An app that wants real OTLP spans replaces the
7
+ # whole strategy:
8
+ #
9
+ # Loam::Telemetry.backend = ->(name, attributes, work) do
10
+ # Tracer.in_span(name, attributes: attributes) { work.call }
11
+ # end
12
+ #
13
+ # Loam instruments its async/background hot paths (scheduler tick, durable
14
+ # event delivery, inbound webhook ingest) so an operator sees them without
15
+ # wiring anything per-call.
16
+ module Telemetry
17
+ module_function
18
+
19
+ # Run `block` as a span named `name` with `attributes`. Returns the block's
20
+ # value. A nil/absent block is a no-op that returns nil.
21
+ def span(name, **attributes, &block)
22
+ return block&.call unless block_given?
23
+
24
+ if backend
25
+ backend.call(name.to_s, attributes, block)
26
+ else
27
+ ActiveSupport::Notifications.instrument("loam.span.#{name}", **attributes, &block)
28
+ end
29
+ end
30
+
31
+ def backend
32
+ @backend
33
+ end
34
+
35
+ def backend=(callable)
36
+ @backend = callable
37
+ end
38
+
39
+ def reset!
40
+ @backend = nil
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ module Loam
2
+ # Base class for every tenant-scoped model. Inheriting from it is what makes
3
+ # a model a "Loam entity":
4
+ #
5
+ # * every query is scoped to Loam::Current.tenant via default_scope
6
+ # * building/creating a record assigns the current tenant automatically
7
+ # * touching the model with NO tenant in context raises MissingTenantError
8
+ #
9
+ # Escape hatch: `Model.unscoped` skips the scope. It is deliberately the
10
+ # standard Rails spelling so it is trivially greppable in review, and
11
+ # AGENTS.md forbids agents from using it.
12
+ class TenantRecord < ActiveRecord::Base
13
+ self.abstract_class = true
14
+
15
+ # Generates the key when it is not an integer (see Loam::GeneratedKey).
16
+ include Loam::GeneratedKey
17
+
18
+ belongs_to :tenant, class_name: "Loam::Tenant"
19
+
20
+ default_scope { where(tenant_id: Loam.tenant!.id) }
21
+
22
+ validates :tenant_id, presence: true
23
+
24
+ before_validation on: :create do
25
+ self.tenant_id ||= Loam.tenant!.id
26
+ end
27
+
28
+ # Guardrail for writes: a record can never be saved into a foreign tenant.
29
+ before_save do
30
+ if tenant_id != Loam.tenant!.id
31
+ raise MissingTenantError, "Attempted to write #{self.class.name} into tenant #{tenant_id} " \
32
+ "while Loam::Current.tenant is #{Loam.tenant!.id}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,15 @@
1
+ module Loam
2
+ # Mixed into ActiveSupport::TestCase by the install generator.
3
+ module TestHelpers
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ setup { Loam::Current.reset }
8
+ teardown { Loam::Current.reset }
9
+ end
10
+
11
+ def with_tenant(tenant, actor: nil, &block)
12
+ Loam.as_tenant(tenant, actor: actor, &block)
13
+ end
14
+ end
15
+ end
data/lib/loam/totp.rb ADDED
@@ -0,0 +1,63 @@
1
+ require "openssl"
2
+ require "securerandom"
3
+ require "uri"
4
+ require "erb"
5
+
6
+ module Loam
7
+ # RFC 6238 time-based one-time passwords — the shipped second factor.
8
+ # HMAC-SHA1, 30-second step, 6 digits, ±1 step of drift tolerance (so a code
9
+ # entered a few seconds either side of a boundary still verifies). Hand-rolled
10
+ # on OpenSSL rather than pulling a gem; it is ~20 lines of a well-specified
11
+ # algorithm, verified against the RFC's own test vectors in the test suite.
12
+ module Totp
13
+ DIGITS = 6
14
+ PERIOD = 30 # seconds per step
15
+ DRIFT = 1 # accept the neighbouring step on each side
16
+
17
+ # A fresh random secret, base32-encoded for authenticator apps. 20 bytes
18
+ # (160 bits) is the RFC-recommended size for SHA1.
19
+ def self.generate_secret(bytes: 20)
20
+ Loam::Base32.encode(SecureRandom.random_bytes(bytes))
21
+ end
22
+
23
+ # True if `code` is valid for `secret` right now.
24
+ def self.verify(secret, code, at: Time.now.to_i)
25
+ !matching_step(secret, code, at: at).nil?
26
+ end
27
+
28
+ # The step counter a code matches (within the drift window), or nil. Callers
29
+ # that must prevent replay (login, sudo) persist this and reject a code whose
30
+ # step is not strictly greater than the last accepted one. The length/charset
31
+ # check runs FIRST, so a malformed code never reaches the constant-time
32
+ # compare (which assumes equal-length inputs).
33
+ def self.matching_step(secret, code, at: Time.now.to_i)
34
+ code = code.to_s.gsub(/\s+/, "")
35
+ return nil unless code.match?(/\A\d{#{DIGITS}}\z/)
36
+
37
+ counter = at.to_i / PERIOD
38
+ (-DRIFT..DRIFT).each do |offset|
39
+ step = counter + offset
40
+ return step if ActiveSupport::SecurityUtils.secure_compare(code_at(secret, step), code)
41
+ end
42
+ nil
43
+ end
44
+
45
+ # The HOTP code for a given step counter (RFC 4226 dynamic truncation).
46
+ def self.code_at(secret, counter, digits: DIGITS)
47
+ hmac = OpenSSL::HMAC.digest("SHA1", Loam::Base32.decode(secret), [counter].pack("Q>"))
48
+ offset = hmac.bytes.last & 0x0f
49
+ truncated = hmac[offset, 4].unpack1("N") & 0x7fffffff
50
+ (truncated % (10**digits)).to_s.rjust(digits, "0")
51
+ end
52
+
53
+ # The otpauth:// URI an authenticator app imports (usually via QR — rendering
54
+ # the QR image is a UI nicety left for later; the URI + secret are enough).
55
+ def self.provisioning_uri(secret, account:, issuer:)
56
+ label = ERB::Util.url_encode("#{issuer}:#{account}")
57
+ query = URI.encode_www_form(
58
+ secret: secret, issuer: issuer, algorithm: "SHA1", digits: DIGITS, period: PERIOD
59
+ )
60
+ "otpauth://totp/#{label}?#{query}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,88 @@
1
+ module Loam
2
+ # Per-record, per-locale translations of DATA fields — a product name, a
3
+ # category label — distinct from Rails i18n (developer UI strings, still
4
+ # Rails-native and out of scope here).
5
+ #
6
+ # class Equipment < Loam::TenantRecord
7
+ # include Loam::Translatable
8
+ # translates :name, :description
9
+ # end
10
+ #
11
+ # READ OVERLAY: the field reader returns the translation for the CURRENT locale
12
+ # (Loam.locale) when one exists, else the record's own COLUMN value — the base
13
+ # is the fallback and stays authoritative for the default locale:
14
+ #
15
+ # equipment.name # => overlay for Loam.locale, else base
16
+ # equipment.name(locale: "de") # => explicit locale, else base
17
+ # equipment.set_translation(:name, "de", "Bagger")
18
+ # equipment.translations_for(:name) # => { "de" => "Bagger", ... }
19
+ #
20
+ # Writes still go through the ordinary `name=` writer to the base column, so
21
+ # translations are purely additive rows in loam_translations — the base value
22
+ # is never overwritten or lost.
23
+ module Translatable
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ class_attribute :loam_translatable_attributes, default: [].freeze, instance_writer: false
28
+
29
+ has_many :loam_translations, class_name: "Loam::Translation",
30
+ as: :translatable, dependent: :delete_all
31
+ end
32
+
33
+ class_methods do
34
+ def translates(*names)
35
+ names = names.map(&:to_s)
36
+
37
+ # An encrypted field must NOT be translatable: a translation row would
38
+ # store its PLAINTEXT in loam_translations, recreating exactly the leak
39
+ # Loam::Encryptable closes (same posture as searchable_by's refusal).
40
+ if respond_to?(:loam_encrypted_attributes)
41
+ conflict = names & loam_encrypted_attributes
42
+ if conflict.any?
43
+ raise Loam::Error,
44
+ "#{name}: cannot `translates` encrypted field(s) #{conflict.join(', ')} — " \
45
+ "a translation would store plaintext. Encrypted data is not translatable."
46
+ end
47
+ end
48
+
49
+ self.loam_translatable_attributes = (loam_translatable_attributes + names).freeze
50
+
51
+ names.each do |field|
52
+ # The overlay reader. `read_attribute` (not super) is the base value,
53
+ # so it works regardless of AR's lazy attribute-method definition.
54
+ define_method(field) do |locale: nil|
55
+ loam_translation_value(field, locale) || read_attribute(field)
56
+ end
57
+ end
58
+ end
59
+
60
+ def loam_translatable?(field)
61
+ loam_translatable_attributes.include?(field.to_s)
62
+ end
63
+ end
64
+
65
+ # The stored translation for a field in a locale (default: the current one),
66
+ # or nil when there is none.
67
+ def loam_translation_value(field, locale = nil)
68
+ locale = (locale || Loam.locale).to_s
69
+ return nil if locale.blank?
70
+
71
+ loam_translations.detect { |t| t.field == field.to_s && t.locale == locale }&.value
72
+ end
73
+
74
+ def set_translation(field, locale, value)
75
+ raise Loam::Error, "#{field} is not translatable on #{self.class.name}" unless self.class.loam_translatable?(field)
76
+
77
+ row = loam_translations.find_or_initialize_by(field: field.to_s, locale: locale.to_s)
78
+ row.value = value
79
+ row.save!
80
+ loam_translations.reset
81
+ value
82
+ end
83
+
84
+ def translations_for(field)
85
+ loam_translations.select { |t| t.field == field.to_s }.to_h { |t| [ t.locale, t.value ] }
86
+ end
87
+ end
88
+ end
data/lib/loam/undo.rb ADDED
@@ -0,0 +1,126 @@
1
+ module Loam
2
+ # Undo/redo built ON the audit trail (L-704). Every change already records a
3
+ # Loam::AuditRecord with its changeset; undoing one applies the inverse and
4
+ # writes a NEW audit record — so the inverse is itself undoable, and "redo" is
5
+ # just undoing that undo entry. No separate command log to keep in sync.
6
+ #
7
+ # THE STACK: a field revert is refused unless the audit is the record's LATEST
8
+ # update/undo — undoing an old change out from under newer ones would silently
9
+ # clobber them. Undo therefore walks the history back one entry at a time.
10
+ #
11
+ # BOUNDARIES (each a deliberate skip, not a gap):
12
+ # * encrypted fields — the audit stores "[encrypted]", never the old value,
13
+ # so there is nothing to revert to;
14
+ # * the workflow column — a direct write is blocked by the transition gate
15
+ # (Loam::Workflow), so state is undone by performing the reverse TRANSITION,
16
+ # not here;
17
+ # * fields the actor's role may not write (when a policy is supplied).
18
+ module Undo
19
+ class NotUndoableError < Loam::Error; end
20
+
21
+ IGNORE = %w[id lock_version created_at updated_at].freeze
22
+
23
+ module_function
24
+
25
+ # Coarse, action-level check the admin view consults to decide whether to
26
+ # offer an Undo button. The fine checks (something revertable, still latest)
27
+ # happen in `undo` and surface as NotUndoableError.
28
+ def undoable?(audit)
29
+ case audit.action
30
+ when "update", "undo" then true
31
+ when "create", "soft_delete", "restore" then soft_deletable?(audit)
32
+ else false
33
+ end
34
+ end
35
+
36
+ # Apply the inverse of one audit record. Returns the affected record.
37
+ # `policy:` (optional) restricts a field revert to policy-writable fields.
38
+ def undo(audit, policy: nil)
39
+ ensure_current_tenant!(audit)
40
+ record = load_record(audit)
41
+
42
+ case audit.action
43
+ when "update", "undo" then revert_fields(record, audit, policy)
44
+ when "create" then undo_create(record)
45
+ when "soft_delete" then guard_soft_delete(record).restore!
46
+ when "restore" then guard_soft_delete(record).soft_delete!
47
+ else raise NotUndoableError, "#{audit.action.inspect} cannot be undone"
48
+ end
49
+ record
50
+ end
51
+
52
+ # --- internals ---
53
+
54
+ def revert_fields(record, audit, policy)
55
+ ensure_latest!(audit)
56
+ workflow_column = record.class.respond_to?(:loam_workflow) ? record.class.loam_workflow&.column.to_s : nil
57
+
58
+ updates = {}
59
+ (audit.changeset || {}).each do |field, values|
60
+ next unless values.is_a?(Array) # skip "[encrypted]" (a String)
61
+ next if IGNORE.include?(field)
62
+ next if field == workflow_column # undo state via the reverse transition, not a direct write
63
+ next unless record.class.column_names.include?(field)
64
+ next if policy && !policy.writable?(field)
65
+
66
+ updates[field] = values.first # the "before" value
67
+ end
68
+
69
+ raise NotUndoableError, "nothing revertable in this change" if updates.empty?
70
+
71
+ # Relabel this save's audit as "undo" so the history reads legibly (and the
72
+ # entry is itself undoable = redo). loam_audit_as is the same hook
73
+ # SoftDeletable uses for soft_delete/restore.
74
+ if record.respond_to?(:loam_audit_as, true)
75
+ record.send(:loam_audit_as, "undo") { record.update!(updates) }
76
+ else
77
+ record.update!(updates)
78
+ end
79
+ end
80
+
81
+ def undo_create(record)
82
+ guard_soft_delete(record).soft_delete!
83
+ end
84
+
85
+ def guard_soft_delete(record)
86
+ unless record.respond_to?(:soft_delete!) && record.respond_to?(:restore!)
87
+ raise NotUndoableError, "#{record.class.name} is not soft-deletable, so this change can't be undone"
88
+ end
89
+ record
90
+ end
91
+
92
+ # The audit must be the record's most recent update/undo — else undoing it
93
+ # would overwrite newer edits. Tenant-scoped via AuditRecord's default scope.
94
+ def ensure_latest!(audit)
95
+ latest = Loam::AuditRecord
96
+ .where(auditable_type: audit.auditable_type, auditable_id: audit.auditable_id, action: %w[update undo])
97
+ .order(:id).last
98
+ return if latest.nil? || latest.id == audit.id
99
+
100
+ raise NotUndoableError, "a newer change exists — undo that first"
101
+ end
102
+
103
+ # Look the record up through its OWN default scope (tenant holds), lifting
104
+ # only the soft-delete filter so a soft-deleted row is still reachable to
105
+ # restore. Never AuditRecord#auditable, which is unscoped.
106
+ def load_record(audit)
107
+ klass = audit.auditable_type.safe_constantize
108
+ raise NotUndoableError, "unknown type #{audit.auditable_type.inspect}" unless klass.is_a?(Class) && klass < Loam::TenantRecord
109
+
110
+ scope = klass.respond_to?(:with_deleted) ? klass.with_deleted : klass.all
111
+ record = scope.find_by(id: audit.auditable_id)
112
+ raise NotUndoableError, "the record no longer exists" unless record
113
+
114
+ record
115
+ end
116
+
117
+ def soft_deletable?(audit)
118
+ klass = audit.auditable_type.safe_constantize
119
+ klass.is_a?(Class) && klass.instance_methods.include?(:soft_delete!)
120
+ end
121
+
122
+ def ensure_current_tenant!(audit)
123
+ raise NotUndoableError, "not in this tenant" unless audit.tenant_id == Loam.tenant!.id
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,3 @@
1
+ module Loam
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,34 @@
1
+ module Loam
2
+ # Domain events, delivered over HTTP to whoever asked for them.
3
+ #
4
+ # One global subscriber listens to every Loam event and, in the tenant the
5
+ # event was published in, enqueues a delivery per matching active endpoint.
6
+ # Matching happens at dispatch time rather than at subscribe time because
7
+ # endpoints are data: a tenant can add one from the admin without a reboot.
8
+ module Webhooks
9
+ # Wired once from Loam::Engine. Idempotent: subscribing twice would deliver
10
+ # every event twice.
11
+ def self.subscribe!
12
+ @subscription ||= Events.subscribe_all { |event_name, payload| dispatch(event_name, payload) }
13
+ end
14
+
15
+ # Runs inline in whatever published the event, so it stays cheap: one
16
+ # query for the tenant's active endpoints, then the HTTP call is a job.
17
+ def self.dispatch(event_name, payload)
18
+ tenant = Tenant.find_by(id: payload[:tenant_id])
19
+ return if tenant.nil?
20
+
21
+ # Only JSON-safe primitives cross into the job — event payloads are ids
22
+ # and scalars by convention, never records.
23
+ deliverable = payload.transform_keys(&:to_s)
24
+
25
+ Loam.as_tenant(tenant) do
26
+ WebhookEndpoint.active.each do |endpoint|
27
+ next unless endpoint.matches?(event_name)
28
+
29
+ WebhookDeliveryJob.perform_later(tenant.id, endpoint.id, event_name, deliverable)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end