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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +71 -0
- data/LICENSE +21 -0
- data/README.md +177 -0
- data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
- data/app/jobs/loam/event_delivery_job.rb +26 -0
- data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
- data/app/jobs/loam/webhook_delivery_job.rb +59 -0
- data/app/models/loam/api_token.rb +40 -0
- data/app/models/loam/audit_record.rb +18 -0
- data/app/models/loam/auth_attempt.rb +13 -0
- data/app/models/loam/business_rule.rb +49 -0
- data/app/models/loam/business_rule_run.rb +27 -0
- data/app/models/loam/comment.rb +28 -0
- data/app/models/loam/config.rb +27 -0
- data/app/models/loam/custom_field_value.rb +18 -0
- data/app/models/loam/dashboard_widget.rb +16 -0
- data/app/models/loam/dictionary.rb +20 -0
- data/app/models/loam/dictionary_entry.rb +31 -0
- data/app/models/loam/event_delivery.rb +38 -0
- data/app/models/loam/field_definition.rb +48 -0
- data/app/models/loam/inbound_webhook_delivery.rb +24 -0
- data/app/models/loam/inbound_webhook_source.rb +55 -0
- data/app/models/loam/membership.rb +27 -0
- data/app/models/loam/mfa_credential.rb +114 -0
- data/app/models/loam/notification.rb +36 -0
- data/app/models/loam/pending_action.rb +208 -0
- data/app/models/loam/perspective.rb +104 -0
- data/app/models/loam/progress_job.rb +103 -0
- data/app/models/loam/record_lock.rb +19 -0
- data/app/models/loam/scheduled_job.rb +42 -0
- data/app/models/loam/search_token.rb +9 -0
- data/app/models/loam/sso_identity.rb +13 -0
- data/app/models/loam/sso_provider.rb +35 -0
- data/app/models/loam/tenant.rb +20 -0
- data/app/models/loam/translation.rb +17 -0
- data/app/models/loam/webhook_endpoint.rb +26 -0
- data/app/views/loam/attachments/_attachments.html.erb +16 -0
- data/app/views/loam/comments/_comments.html.erb +24 -0
- data/app/views/loam/custom_fields/_fields.html.erb +35 -0
- data/lib/generators/loam/entity/entity_generator.rb +160 -0
- data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
- data/lib/generators/loam/entity/templates/controller.rb +181 -0
- data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
- data/lib/generators/loam/entity/templates/migration.rb +26 -0
- data/lib/generators/loam/entity/templates/model.rb +27 -0
- data/lib/generators/loam/entity/templates/policy.rb +10 -0
- data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
- data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
- data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
- data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
- data/lib/generators/loam/install/install_generator.rb +288 -0
- data/lib/generators/loam/install/templates/AGENTS.md +341 -0
- data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
- data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
- data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
- data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
- data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
- data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
- data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
- data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
- data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
- data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
- data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
- data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
- data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
- data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
- data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
- data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
- data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
- data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
- data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
- data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
- data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
- data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
- data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
- data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
- data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
- data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
- data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
- data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
- data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
- data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
- data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
- data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
- data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
- data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
- data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
- data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
- data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
- data/lib/generators/loam/install/templates/admin.css +29 -0
- data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
- data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
- data/lib/generators/loam/install/templates/import_job.rb +25 -0
- data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
- data/lib/generators/loam/install/templates/initializer.rb +218 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
- data/lib/generators/loam/install/templates/user.rb +21 -0
- data/lib/generators/loam/primary_key_options.rb +101 -0
- data/lib/loam/attachable.rb +27 -0
- data/lib/loam/auditable.rb +67 -0
- data/lib/loam/auth_throttle.rb +83 -0
- data/lib/loam/base32.rb +23 -0
- data/lib/loam/bulk.rb +59 -0
- data/lib/loam/business_rules/actions.rb +92 -0
- data/lib/loam/business_rules/condition.rb +102 -0
- data/lib/loam/business_rules.rb +123 -0
- data/lib/loam/commentable.rb +22 -0
- data/lib/loam/configs.rb +115 -0
- data/lib/loam/cron.rb +98 -0
- data/lib/loam/csv.rb +18 -0
- data/lib/loam/current.rb +21 -0
- data/lib/loam/custom_field_index.rb +262 -0
- data/lib/loam/custom_fields.rb +114 -0
- data/lib/loam/dashboard.rb +17 -0
- data/lib/loam/dictionaries.rb +55 -0
- data/lib/loam/durable_events.rb +174 -0
- data/lib/loam/encryptable.rb +145 -0
- data/lib/loam/encryption/cipher.rb +75 -0
- data/lib/loam/encryption/key_provider.rb +43 -0
- data/lib/loam/encryption.rb +125 -0
- data/lib/loam/engine.rb +57 -0
- data/lib/loam/enrichers.rb +105 -0
- data/lib/loam/errors.rb +49 -0
- data/lib/loam/eval.rb +47 -0
- data/lib/loam/event_stream.rb +80 -0
- data/lib/loam/eventful.rb +45 -0
- data/lib/loam/events.rb +51 -0
- data/lib/loam/export.rb +74 -0
- data/lib/loam/features.rb +75 -0
- data/lib/loam/generated_key.rb +37 -0
- data/lib/loam/import.rb +168 -0
- data/lib/loam/inbound_webhooks.rb +125 -0
- data/lib/loam/lifecycle.rb +180 -0
- data/lib/loam/locales/loam.en.yml +80 -0
- data/lib/loam/mcp/server.rb +60 -0
- data/lib/loam/mcp.rb +304 -0
- data/lib/loam/notifications.rb +36 -0
- data/lib/loam/open_api.rb +194 -0
- data/lib/loam/overrides.rb +110 -0
- data/lib/loam/pending_actions.rb +60 -0
- data/lib/loam/permissions.rb +79 -0
- data/lib/loam/perspectives.rb +62 -0
- data/lib/loam/policy.rb +106 -0
- data/lib/loam/progress.rb +31 -0
- data/lib/loam/record_locks.rb +89 -0
- data/lib/loam/scheduler.rb +153 -0
- data/lib/loam/search/driver.rb +31 -0
- data/lib/loam/search/like_driver.rb +29 -0
- data/lib/loam/search/token_driver.rb +88 -0
- data/lib/loam/search.rb +27 -0
- data/lib/loam/searchable.rb +90 -0
- data/lib/loam/soft_deletable.rb +72 -0
- data/lib/loam/sso/claims.rb +10 -0
- data/lib/loam/sso/fake_provider.rb +51 -0
- data/lib/loam/sso/http_client.rb +42 -0
- data/lib/loam/sso/oidc_provider.rb +64 -0
- data/lib/loam/sso.rb +129 -0
- data/lib/loam/telemetry.rb +43 -0
- data/lib/loam/tenant_record.rb +36 -0
- data/lib/loam/test_helpers.rb +15 -0
- data/lib/loam/totp.rb +63 -0
- data/lib/loam/translatable.rb +88 -0
- data/lib/loam/undo.rb +126 -0
- data/lib/loam/version.rb +3 -0
- data/lib/loam/webhooks.rb +34 -0
- data/lib/loam/widgets.rb +77 -0
- data/lib/loam/workflow.rb +256 -0
- data/lib/loam.rb +122 -0
- data/lib/open-loam.rb +5 -0
- data/lib/tasks/loam.rake +171 -0
- data/lib/tasks/loam_eval.rake +32 -0
- data/lib/tasks/loam_mcp.rake +20 -0
- 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
|
data/lib/loam/version.rb
ADDED
|
@@ -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
|