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,102 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module BusinessRules
|
|
3
|
+
# The SAFE condition evaluator — the critical security boundary of the rules
|
|
4
|
+
# engine. A rule is DATA an admin edits, so this must NEVER run arbitrary
|
|
5
|
+
# code: it supports only `and`/`or`/`not` and leaf comparisons
|
|
6
|
+
# `{ field, op, value }`, and a `field` may name ONLY a real, non-plumbing,
|
|
7
|
+
# non-encrypted column of the record OR a declared custom field. Anything
|
|
8
|
+
# else is refused — no `send` of arbitrary methods, no SQL, no eval.
|
|
9
|
+
module Condition
|
|
10
|
+
OPS = %w[eq neq gt gte lt lte in contains present blank].freeze
|
|
11
|
+
REFUSED = :__loam_field_refused__
|
|
12
|
+
|
|
13
|
+
def self.matches?(node, record)
|
|
14
|
+
node = node.is_a?(Hash) ? node : {}
|
|
15
|
+
return true if node.empty? # no condition = always fires
|
|
16
|
+
|
|
17
|
+
if node.key?("and")
|
|
18
|
+
Array(node["and"]).all? { |child| matches?(child, record) }
|
|
19
|
+
elsif node.key?("or")
|
|
20
|
+
Array(node["or"]).any? { |child| matches?(child, record) }
|
|
21
|
+
elsif node.key?("not")
|
|
22
|
+
!matches?(node["not"], record)
|
|
23
|
+
else
|
|
24
|
+
leaf(node, record)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.leaf(node, record)
|
|
29
|
+
op = node["op"].to_s
|
|
30
|
+
return false unless OPS.include?(op)
|
|
31
|
+
|
|
32
|
+
actual = read_field(node["field"].to_s, record)
|
|
33
|
+
return false if actual == REFUSED # an un-whitelisted field never matches
|
|
34
|
+
|
|
35
|
+
compare(op, actual, node["value"])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A whitelisted read, or REFUSED. `tenant_id` is refused as an isolation
|
|
39
|
+
# footgun; ENCRYPTED columns are refused because a contains/eq rule over
|
|
40
|
+
# their decrypted value would be an oracle leaking the secret through the
|
|
41
|
+
# rule's behaviour. Everything unknown is refused, never sent.
|
|
42
|
+
def self.read_field(field, record)
|
|
43
|
+
klass = record.class
|
|
44
|
+
return REFUSED if refused_column?(klass, field)
|
|
45
|
+
|
|
46
|
+
if klass.column_names.include?(field)
|
|
47
|
+
record.public_send(field)
|
|
48
|
+
elsif custom_field?(record, field)
|
|
49
|
+
begin
|
|
50
|
+
record.custom_field(field)
|
|
51
|
+
rescue Loam::UnknownCustomFieldError
|
|
52
|
+
REFUSED
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
REFUSED
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.refused_column?(klass, field)
|
|
60
|
+
return true if field == "tenant_id"
|
|
61
|
+
|
|
62
|
+
klass.respond_to?(:loam_encrypted_attributes) &&
|
|
63
|
+
klass.loam_encrypted_attributes.map(&:to_s).include?(field)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.custom_field?(record, field)
|
|
67
|
+
record.class.respond_to?(:custom_field_definitions) &&
|
|
68
|
+
record.class.custom_field_definitions.exists?(name: field)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.compare(op, actual, expected)
|
|
72
|
+
case op
|
|
73
|
+
when "eq" then values_equal?(actual, expected)
|
|
74
|
+
when "neq" then !values_equal?(actual, expected)
|
|
75
|
+
when "in" then Array(expected).map(&:to_s).include?(actual.to_s)
|
|
76
|
+
when "contains" then actual.to_s.include?(expected.to_s)
|
|
77
|
+
when "present" then actual.present?
|
|
78
|
+
when "blank" then actual.blank?
|
|
79
|
+
else
|
|
80
|
+
cmp = numeric_compare(actual, expected)
|
|
81
|
+
return false if cmp.nil?
|
|
82
|
+
|
|
83
|
+
{ "gt" => cmp.positive?, "gte" => cmp >= 0, "lt" => cmp.negative?, "lte" => cmp <= 0 }[op]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.values_equal?(actual, expected)
|
|
88
|
+
actual == expected || actual.to_s == expected.to_s || numeric_compare(actual, expected)&.zero? || false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Compare as numbers when both coerce; else fall back to the natural
|
|
92
|
+
# comparison; incomparable types yield nil (so the op is simply false).
|
|
93
|
+
def self.numeric_compare(actual, expected)
|
|
94
|
+
Float(actual) <=> Float(expected)
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
actual <=> expected
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "loam/business_rules/condition"
|
|
2
|
+
require "loam/business_rules/actions"
|
|
3
|
+
|
|
4
|
+
module Loam
|
|
5
|
+
# The engine that evaluates Loam::BusinessRule rows: WHEN a trigger fires and
|
|
6
|
+
# the condition holds, THEN run the actions — in the event's tenant context,
|
|
7
|
+
# in priority order, each rule isolated so one failure never breaks dispatch.
|
|
8
|
+
#
|
|
9
|
+
# It listens to every Loam event (like Loam::Webhooks), so a tenant can add a
|
|
10
|
+
# rule from the admin without a reboot. Evaluation runs INLINE in whatever
|
|
11
|
+
# published the event — cheap for the prototype; a job-based dispatcher is the
|
|
12
|
+
# scaling path.
|
|
13
|
+
module BusinessRules
|
|
14
|
+
MAX_DEPTH = 3 # bounds a rule chain: an emit_event action can trigger more
|
|
15
|
+
# rules, but only so deep, so a self-triggering rule terminates.
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Wired once from Loam::Engine. Idempotent — subscribing twice would run
|
|
19
|
+
# every rule twice (see Loam::Webhooks.subscribe!).
|
|
20
|
+
def subscribe!
|
|
21
|
+
@subscription ||= Loam::Events.subscribe_all { |event_name, payload| on_event(event_name, payload) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def on_event(event_name, payload)
|
|
25
|
+
tenant = Loam::Tenant.find_by(id: payload[:tenant_id])
|
|
26
|
+
return if tenant.nil?
|
|
27
|
+
|
|
28
|
+
Loam.as_tenant(tenant) do
|
|
29
|
+
within_depth_guard do
|
|
30
|
+
Loam::BusinessRule.active.by_priority.each do |rule|
|
|
31
|
+
next unless rule.matches_trigger?(event_name)
|
|
32
|
+
|
|
33
|
+
run_rule(rule, subject_for(rule, payload), event_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Manual evaluation for a record — for programmatic/test use. Runs the
|
|
40
|
+
# active rules for the record's entity type (optionally filtered to a
|
|
41
|
+
# trigger) in priority order.
|
|
42
|
+
def evaluate(record, trigger: nil)
|
|
43
|
+
Loam::BusinessRule.active.for_entity(record.class.base_class.name).by_priority.each do |rule|
|
|
44
|
+
next if trigger && !rule.matches_trigger?(trigger)
|
|
45
|
+
|
|
46
|
+
run_rule(rule, record, trigger)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Do the active rules with a block_transition action, whose condition
|
|
51
|
+
# holds, veto this transition? Consulted by a workflow BEFORE it moves (see
|
|
52
|
+
# the demo's DamageReport). Evaluate-only — no actions run, nothing logged.
|
|
53
|
+
def veto?(record, trigger)
|
|
54
|
+
Loam::BusinessRule.active.for_entity(record.class.base_class.name).by_priority.any? do |rule|
|
|
55
|
+
rule.matches_trigger?(trigger) &&
|
|
56
|
+
rule.action_list.any? { |action| action["type"].to_s == "block_transition" } &&
|
|
57
|
+
Condition.matches?(rule.condition_tree, record)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def run_rule(rule, record, event_name)
|
|
64
|
+
if record.nil? && rule.entity_type.present?
|
|
65
|
+
return log(rule, nil, event_name, matched: false, error: "subject not found")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
matched = Condition.matches?(rule.condition_tree, record)
|
|
69
|
+
taken = []
|
|
70
|
+
# block_transition is a veto signal consumed elsewhere, not an action to
|
|
71
|
+
# perform here, so it is skipped in the THEN path.
|
|
72
|
+
rule.action_list.each { |action| taken << Actions.run(action, record) } if matched && record
|
|
73
|
+
|
|
74
|
+
log(rule, record, event_name, matched: matched, actions_taken: taken.compact) if matched || taken.any?
|
|
75
|
+
rescue StandardError => error
|
|
76
|
+
log(rule, record, event_name, matched: false, error: error.message)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def log(rule, record, event_name, matched:, actions_taken: [], error: nil)
|
|
80
|
+
# Lean: log the runs that ACTED, errored, or found no subject — not the
|
|
81
|
+
# deterministic "condition didn't match" ones (readable from the rule).
|
|
82
|
+
return if !matched && error.nil?
|
|
83
|
+
|
|
84
|
+
Loam::BusinessRuleRun.create!(
|
|
85
|
+
business_rule: rule,
|
|
86
|
+
subject_type: record&.class&.base_class&.name,
|
|
87
|
+
subject_id: record&.id,
|
|
88
|
+
event_name: event_name,
|
|
89
|
+
matched: matched,
|
|
90
|
+
actions_taken: actions_taken,
|
|
91
|
+
error: error
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A rule watches a specific entity; load THAT record from the rule's
|
|
96
|
+
# entity_type + the event's id (find_by, not find — a record deleted
|
|
97
|
+
# between publish and dispatch is a missing subject, not a crash).
|
|
98
|
+
def subject_for(rule, payload)
|
|
99
|
+
return nil if rule.entity_type.blank? || payload[:id].blank?
|
|
100
|
+
|
|
101
|
+
klass = rule.entity_type.safe_constantize
|
|
102
|
+
# A rule may ONLY target a tenant-scoped model. This is the security
|
|
103
|
+
# boundary: a global model like `User` has no tenant default scope, so a
|
|
104
|
+
# find_by(id:) would reach across tenants — refusing anything that is not
|
|
105
|
+
# a Loam::TenantRecord closes that, and a TenantRecord.find_by can only
|
|
106
|
+
# ever return a record in the CURRENT tenant even if an id is injected.
|
|
107
|
+
return nil unless klass.is_a?(Class) && klass < Loam::TenantRecord
|
|
108
|
+
|
|
109
|
+
klass.find_by(id: payload[:id])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def within_depth_guard
|
|
113
|
+
depth = Thread.current[:loam_rule_depth] ||= 0
|
|
114
|
+
return if depth >= MAX_DEPTH
|
|
115
|
+
|
|
116
|
+
Thread.current[:loam_rule_depth] = depth + 1
|
|
117
|
+
yield
|
|
118
|
+
ensure
|
|
119
|
+
Thread.current[:loam_rule_depth] = depth if depth
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Discussion on a record:
|
|
3
|
+
#
|
|
4
|
+
# equipment.comment!("Hydraulics serviced") # as Loam.actor
|
|
5
|
+
# equipment.loam_comments.oldest_first
|
|
6
|
+
#
|
|
7
|
+
# Included by every generated entity. Comments are tenant-scoped like their
|
|
8
|
+
# subject, so a discussion can never be read from another tenant.
|
|
9
|
+
module Commentable
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
has_many :loam_comments, as: :commentable, class_name: "Loam::Comment", dependent: :destroy
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Author defaults to whoever is acting — the same person the audit trail
|
|
17
|
+
# and the event payload would name.
|
|
18
|
+
def comment!(body, author: Loam::Current.actor)
|
|
19
|
+
loam_comments.create!(body: body, author: author)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/loam/configs.rb
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The one way to read and write per-tenant settings.
|
|
3
|
+
#
|
|
4
|
+
# Loam::Configs.get("rental.late_fee_per_day") # resolved for the current tenant
|
|
5
|
+
# Loam::Configs.set("rental.late_fee_per_day", 45) # this tenant's override
|
|
6
|
+
# Loam::Configs.set("rental.currency", "PLN", scope: :global)
|
|
7
|
+
# Loam::Configs.reset("rental.late_fee_per_day") # drop the override → fall back
|
|
8
|
+
#
|
|
9
|
+
# Resolution order, most specific first:
|
|
10
|
+
# 1. the current tenant's OVERRIDE row (tenant_id = current tenant)
|
|
11
|
+
# 2. the GLOBAL row (tenant_id NULL)
|
|
12
|
+
# 3. the declared default in Loam.config_defaults[key]
|
|
13
|
+
# 4. the `default:` argument (nil)
|
|
14
|
+
#
|
|
15
|
+
# Reads are memoized per request in Loam::Current.config_cache, keyed by
|
|
16
|
+
# [key, tenant_id], and the whole cache is dropped on any write. That is the
|
|
17
|
+
# deliberately-simple prototype cache; a Rails.cache-backed layer shared across
|
|
18
|
+
# requests is the scaling path, and it would slot in behind this same API.
|
|
19
|
+
module Configs
|
|
20
|
+
class << self
|
|
21
|
+
def get(key, default: nil)
|
|
22
|
+
key = key.to_s
|
|
23
|
+
store = cache
|
|
24
|
+
cache_key = [key, current_tenant_id]
|
|
25
|
+
|
|
26
|
+
return store[cache_key] if store.key?(cache_key)
|
|
27
|
+
|
|
28
|
+
# Only a value that actually RESOLVED (a row or a declared default) is
|
|
29
|
+
# cached — never the caller's `default:` fallback. The cache key omits
|
|
30
|
+
# `default:`, so caching it would make a later get with a different
|
|
31
|
+
# default wrongly return the first one when nothing is configured.
|
|
32
|
+
found, value = resolve(key)
|
|
33
|
+
return default unless found
|
|
34
|
+
|
|
35
|
+
store[cache_key] = value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Write a setting. scope: :tenant writes the current tenant's override and
|
|
39
|
+
# requires a tenant in context (a missing one raises, like every Loam
|
|
40
|
+
# write); scope: :global writes the app-wide row (tenant_id NULL). Upsert.
|
|
41
|
+
def set(key, value, scope: :tenant)
|
|
42
|
+
key = key.to_s
|
|
43
|
+
record = row_for(key, scope).first_or_initialize
|
|
44
|
+
record.value_json = value
|
|
45
|
+
record.save!
|
|
46
|
+
clear_cache
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Drop the current tenant's override for a key so it falls back to the
|
|
51
|
+
# global row / declared default. Requires a tenant in context. No-op if
|
|
52
|
+
# there was no override. Returns true.
|
|
53
|
+
def reset(key)
|
|
54
|
+
row_for(key.to_s, :tenant).delete_all
|
|
55
|
+
clear_cache
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether the current tenant has an override for this key (vs. inheriting
|
|
60
|
+
# the global/declared value). Drives the admin "overridden?" column.
|
|
61
|
+
def overridden?(key)
|
|
62
|
+
row_for(key.to_s, :tenant).exists?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Every key an admin might configure for the current tenant: declared
|
|
66
|
+
# defaults, plus any global or current-tenant rows already written. Sorted
|
|
67
|
+
# and de-duplicated, for the settings screen.
|
|
68
|
+
def defined_keys
|
|
69
|
+
rows = Loam::Config.where(tenant_id: [nil, Loam.tenant&.id]).distinct.pluck(:key)
|
|
70
|
+
(Loam.config_defaults.keys + rows).map(&:to_s).uniq.sort
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Returns [found?, value]. `found?` is false only when nothing resolved —
|
|
76
|
+
# the caller then applies its own `default:` without caching it.
|
|
77
|
+
def resolve(key)
|
|
78
|
+
if (tenant_id = current_tenant_id)
|
|
79
|
+
override = Loam::Config.find_by(key: key, tenant_id: tenant_id)
|
|
80
|
+
return [true, override.value_json] if override
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
global = Loam::Config.find_by(key: key, tenant_id: nil)
|
|
84
|
+
return [true, global.value_json] if global
|
|
85
|
+
|
|
86
|
+
return [true, Loam.config_defaults[key]] if Loam.config_defaults.key?(key)
|
|
87
|
+
|
|
88
|
+
[false, nil]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The row(s) a write/read targets. :global is the tenant_id NULL row;
|
|
92
|
+
# :tenant is the current tenant's — which requires a tenant in context,
|
|
93
|
+
# so Loam.tenant! raises MissingTenantError when there is none.
|
|
94
|
+
def row_for(key, scope)
|
|
95
|
+
case scope
|
|
96
|
+
when :global then Loam::Config.where(key: key, tenant_id: nil)
|
|
97
|
+
when :tenant then Loam::Config.where(key: key, tenant_id: Loam.tenant!.id)
|
|
98
|
+
else raise ArgumentError, "unknown config scope #{scope.inspect} (use :tenant or :global)"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def current_tenant_id
|
|
103
|
+
Loam.tenant&.id
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def cache
|
|
107
|
+
Loam::Current.config_cache ||= {}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clear_cache
|
|
111
|
+
Loam::Current.config_cache = {}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/loam/cron.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
# A small, dependency-free "next run" calculator for a standard 5-field cron
|
|
5
|
+
# expression ("min hour dom month dow") plus an "interval:N" (every N seconds)
|
|
6
|
+
# form — same stdlib-only spirit as Loam::Totp / Loam::Base32. Timezone-aware;
|
|
7
|
+
# always returns UTC. Supports `*`, lists (`1,15`), ranges (`1-5`), and steps
|
|
8
|
+
# (`*/15`, `0-30/10`), and the classic cron day-of-month / day-of-week OR quirk
|
|
9
|
+
# (when BOTH are restricted, a match on either fires).
|
|
10
|
+
module Cron
|
|
11
|
+
RANGES = { min: 0..59, hour: 0..23, dom: 1..31, mon: 1..12, dow: 0..6 }.freeze
|
|
12
|
+
MAX_HORIZON = 4 # years to search before giving up (covers Feb-29 schedules)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The next run strictly after `from`, evaluated in `zone`, returned in UTC.
|
|
17
|
+
def next_after(expr, from:, zone: "UTC")
|
|
18
|
+
expr = expr.to_s.strip
|
|
19
|
+
|
|
20
|
+
if expr.start_with?("interval:")
|
|
21
|
+
seconds = expr.split(":", 2).last.to_i
|
|
22
|
+
raise ArgumentError, "interval must be a positive number of seconds: #{expr.inspect}" unless seconds.positive?
|
|
23
|
+
|
|
24
|
+
return (from + seconds).utc
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
next_cron(expr, from, zone)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def next_cron(expr, from, zone)
|
|
31
|
+
fields = expr.split(/\s+/)
|
|
32
|
+
raise ArgumentError, "cron must have 5 fields (min hour dom month dow): #{expr.inspect}" unless fields.size == 5
|
|
33
|
+
|
|
34
|
+
min = parse_field(fields[0], RANGES[:min])
|
|
35
|
+
hour = parse_field(fields[1], RANGES[:hour])
|
|
36
|
+
dom = parse_field(fields[2], RANGES[:dom])
|
|
37
|
+
mon = parse_field(fields[3], RANGES[:mon])
|
|
38
|
+
dow = parse_field(fields[4], RANGES[:dow], dow: true)
|
|
39
|
+
dom_restricted = fields[2] != "*"
|
|
40
|
+
dow_restricted = fields[4] != "*"
|
|
41
|
+
|
|
42
|
+
time = from.in_time_zone(zone).change(sec: 0) + 60
|
|
43
|
+
limit = time + MAX_HORIZON.years
|
|
44
|
+
|
|
45
|
+
while time < limit
|
|
46
|
+
unless mon.include?(time.month) && day_matches?(time, dom, dow, dom_restricted, dow_restricted)
|
|
47
|
+
time = (time + 1.day).change(hour: 0, min: 0) # skip the whole non-matching day
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
return time.utc if hour.include?(time.hour) && min.include?(time.min)
|
|
51
|
+
|
|
52
|
+
time += 60
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "no cron match within #{MAX_HORIZON} years for #{expr.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def day_matches?(time, dom, dow, dom_restricted, dow_restricted)
|
|
59
|
+
if dom_restricted && dow_restricted
|
|
60
|
+
dom.include?(time.day) || dow.include?(time.wday) # OR quirk
|
|
61
|
+
elsif dom_restricted
|
|
62
|
+
dom.include?(time.day)
|
|
63
|
+
elsif dow_restricted
|
|
64
|
+
dow.include?(time.wday)
|
|
65
|
+
else
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parse one field into the set of matching integers.
|
|
71
|
+
def parse_field(field, range, dow: false)
|
|
72
|
+
values = Set.new
|
|
73
|
+
|
|
74
|
+
field.to_s.split(",").each do |part|
|
|
75
|
+
base, step = part.split("/", 2)
|
|
76
|
+
step = step ? Integer(step) : 1
|
|
77
|
+
raise ArgumentError, "bad step in #{part.inspect}" unless step.positive?
|
|
78
|
+
|
|
79
|
+
if base == "*"
|
|
80
|
+
lo, hi = range.min, range.max
|
|
81
|
+
elsif base.include?("-")
|
|
82
|
+
lo, hi = base.split("-", 2).map { |n| Integer(n) }
|
|
83
|
+
else
|
|
84
|
+
lo = hi = Integer(base)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
(lo..hi).step(step) do |value|
|
|
88
|
+
value = 0 if dow && value == 7 # both 0 and 7 mean Sunday
|
|
89
|
+
raise ArgumentError, "value #{value} out of range for #{field.inspect}" unless range.include?(value)
|
|
90
|
+
|
|
91
|
+
values << value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
values
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/loam/csv.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# CSV formula-injection defense. A cell whose value starts with =, +, -, @, or
|
|
3
|
+
# a tab/CR is treated as a FORMULA by Excel/Google Sheets when the file is
|
|
4
|
+
# opened — so `=cmd|...` or `=HYPERLINK(...)` in imported/exported data can run.
|
|
5
|
+
# Prefixing such a value with a single quote neutralizes it while leaving the
|
|
6
|
+
# visible text intact. Applied on every CSV cell Loam writes (export + the
|
|
7
|
+
# import error file).
|
|
8
|
+
module Csv
|
|
9
|
+
DANGEROUS = /\A[=+\-@\t\r]/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def safe(value)
|
|
14
|
+
string = value.to_s
|
|
15
|
+
string.match?(DANGEROUS) ? "'#{string}" : value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/loam/current.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "active_support/current_attributes"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
# Per-request (and per-job) execution context. Every tenant-scoped query,
|
|
5
|
+
# audit record, and published event reads from here.
|
|
6
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
7
|
+
# config_cache memoizes resolved Loam::Configs values for the duration of
|
|
8
|
+
# one request/job, keyed by [key, tenant_id]. It resets with everything else
|
|
9
|
+
# here, so a value can never be read stale across requests.
|
|
10
|
+
#
|
|
11
|
+
# mutation_mode is the approval-gate seam (Loam::PendingActions): nil/:direct
|
|
12
|
+
# means writes commit normally; :confirm means a caller (an MCP tool acting
|
|
13
|
+
# for an AI agent) stages writes for human approval instead. It lives here so
|
|
14
|
+
# it is per-request and resets automatically.
|
|
15
|
+
# dictionary_cache memoizes resolved Loam::Dictionaries lookups for one
|
|
16
|
+
# request/job, keyed by [kind, key, tenant_id] — same posture as config_cache.
|
|
17
|
+
# locale is the current content-translation language (Loam::Translatable) —
|
|
18
|
+
# request state like tenant/actor, set in a before_action and reset here.
|
|
19
|
+
attribute :tenant, :actor, :config_cache, :mutation_mode, :dictionary_cache, :locale
|
|
20
|
+
end
|
|
21
|
+
end
|