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