open-loam 0.1.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +148 -8
- data/LICENSE +1 -1
- data/README.md +27 -25
- data/app/jobs/{loam → open_loam}/custom_field_reindex_job.rb +6 -6
- data/app/jobs/{loam → open_loam}/event_delivery_job.rb +8 -8
- data/app/jobs/open_loam/event_log_prune_job.rb +15 -0
- data/app/jobs/{loam → open_loam}/event_redelivery_sweep_job.rb +4 -4
- data/app/jobs/{loam → open_loam}/webhook_delivery_job.rb +17 -8
- data/app/models/open_loam/api_token.rb +63 -0
- data/app/models/{loam → open_loam}/audit_record.rb +3 -3
- data/app/models/{loam → open_loam}/auth_attempt.rb +5 -5
- data/app/models/{loam → open_loam}/business_rule.rb +10 -10
- data/app/models/{loam → open_loam}/business_rule_run.rb +5 -5
- data/app/models/{loam → open_loam}/comment.rb +9 -9
- data/app/models/{loam → open_loam}/config.rb +7 -7
- data/app/models/{loam → open_loam}/custom_field_value.rb +5 -5
- data/app/models/{loam → open_loam}/dashboard_widget.rb +5 -5
- data/app/models/open_loam/dictionary.rb +20 -0
- data/app/models/{loam → open_loam}/dictionary_entry.rb +7 -7
- data/app/models/{loam → open_loam}/event_delivery.rb +4 -4
- data/app/models/open_loam/event_record.rb +42 -0
- data/app/models/{loam → open_loam}/field_definition.rb +8 -8
- data/app/models/{loam → open_loam}/inbound_webhook_delivery.rb +4 -4
- data/app/models/{loam → open_loam}/inbound_webhook_source.rb +13 -13
- data/app/models/{loam → open_loam}/membership.rb +6 -6
- data/app/models/{loam → open_loam}/mfa_credential.rb +8 -8
- data/app/models/{loam → open_loam}/notification.rb +10 -10
- data/app/models/{loam → open_loam}/pending_action.rb +70 -26
- data/app/models/{loam → open_loam}/perspective.rb +5 -5
- data/app/models/{loam → open_loam}/progress_job.rb +7 -7
- data/app/models/{loam → open_loam}/record_lock.rb +5 -5
- data/app/models/{loam → open_loam}/scheduled_job.rb +7 -7
- data/app/models/open_loam/search_token.rb +9 -0
- data/app/models/{loam → open_loam}/sso_identity.rb +4 -4
- data/app/models/open_loam/sso_provider.rb +66 -0
- data/app/models/open_loam/tenant.rb +20 -0
- data/app/models/{loam → open_loam}/translation.rb +5 -5
- data/app/models/open_loam/webhook_endpoint.rb +39 -0
- data/app/views/{loam → open_loam}/attachments/_attachments.html.erb +1 -1
- data/app/views/{loam → open_loam}/comments/_comments.html.erb +1 -1
- data/app/views/{loam → open_loam}/custom_fields/_fields.html.erb +6 -2
- data/lib/generators/{loam → open_loam}/entity/entity_generator.rb +9 -9
- data/lib/generators/{loam → open_loam}/entity/templates/api_controller.rb +2 -1
- data/lib/generators/{loam → open_loam}/entity/templates/controller.rb +32 -22
- data/lib/generators/{loam → open_loam}/entity/templates/entity_test.rb +12 -12
- data/lib/generators/{loam → open_loam}/entity/templates/migration.rb +5 -5
- data/lib/generators/{loam → open_loam}/entity/templates/model.rb +10 -10
- data/lib/generators/{loam → open_loam}/entity/templates/policy.rb +1 -1
- data/lib/generators/{loam → open_loam}/entity/templates/views/_form.html.erb +8 -5
- data/lib/generators/{loam → open_loam}/entity/templates/views/deleted.html.erb +7 -7
- data/lib/generators/open_loam/entity/templates/views/edit.html.erb +3 -0
- data/lib/generators/open_loam/entity/templates/views/index.html.erb +94 -0
- data/lib/generators/open_loam/entity/templates/views/new.html.erb +3 -0
- data/lib/generators/open_loam/entity/templates/views/show.html.erb +38 -0
- data/lib/generators/{loam → open_loam}/install/install_generator.rb +58 -47
- data/lib/generators/open_loam/install/templates/AGENTS.md +345 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/api_docs_controller.rb +2 -2
- data/lib/generators/{loam → open_loam}/install/templates/admin/api_docs_index.html.erb +1 -1
- data/lib/generators/open_loam/install/templates/admin/api_tokens_controller.rb +48 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/api_tokens_index.html.erb +23 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/base_controller.rb +101 -38
- data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_controller.rb +7 -7
- data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_form.html.erb +2 -2
- data/lib/generators/{loam → open_loam}/install/templates/admin/comments_controller.rb +4 -4
- data/lib/generators/{loam → open_loam}/install/templates/admin/configs_controller.rb +9 -9
- data/lib/generators/open_loam/install/templates/admin/dashboard_controller.rb +12 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/dashboard_widgets_controller.rb +5 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_controller.rb +5 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_form.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/dictionary_entries_controller.rb +2 -2
- data/lib/generators/{loam → open_loam}/install/templates/admin/event_deliveries_controller.rb +5 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/event_deliveries_index.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/events_controller.rb +7 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/features_controller.rb +9 -9
- data/lib/generators/{loam → open_loam}/install/templates/admin/field_definitions_controller.rb +5 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/field_definitions_new.html.erb +2 -2
- data/lib/generators/open_loam/install/templates/admin/history_controller.rb +64 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/history_index.html.erb +2 -2
- data/lib/generators/{loam → open_loam}/install/templates/admin/imports_controller.rb +25 -13
- data/lib/generators/{loam → open_loam}/install/templates/admin/inbound_webhook_sources_controller.rb +7 -7
- data/lib/generators/{loam → open_loam}/install/templates/admin/inbound_webhook_sources_new.html.erb +2 -4
- data/lib/generators/open_loam/install/templates/admin/layout.html.erb +76 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_controller.rb +11 -6
- data/lib/generators/{loam → open_loam}/install/templates/admin/notifications_controller.rb +5 -3
- data/lib/generators/{loam → open_loam}/install/templates/admin/overrides_controller.rb +3 -3
- data/lib/generators/{loam → open_loam}/install/templates/admin/overrides_index.html.erb +2 -2
- data/lib/generators/{loam → open_loam}/install/templates/admin/pending_actions_controller.rb +3 -3
- data/lib/generators/{loam → open_loam}/install/templates/admin/perspectives_controller.rb +13 -7
- data/lib/generators/{loam → open_loam}/install/templates/admin/perspectives_index.html.erb +1 -1
- data/lib/generators/open_loam/install/templates/admin/progress_jobs_controller.rb +23 -0
- data/lib/generators/{loam → open_loam}/install/templates/admin/progress_jobs_index.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/record_locks_controller.rb +4 -4
- data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_controller.rb +7 -7
- data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_form.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_index.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/search_controller.rb +7 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/sessions_controller.rb +33 -34
- data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_controller.rb +6 -6
- data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_form.html.erb +2 -2
- data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_index.html.erb +8 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/sudo_controller.rb +7 -5
- data/lib/generators/{loam → open_loam}/install/templates/admin/sudo_new.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin/translations_controller.rb +7 -7
- data/lib/generators/{loam → open_loam}/install/templates/admin/translations_index.html.erb +3 -3
- data/lib/generators/{loam → open_loam}/install/templates/admin/webhook_endpoints_controller.rb +4 -4
- data/lib/generators/{loam → open_loam}/install/templates/admin/webhook_endpoints_index.html.erb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/admin.css +1 -1
- data/lib/generators/open_loam/install/templates/api_base_controller.rb +127 -0
- data/lib/generators/{loam → open_loam}/install/templates/guardrails_test.rb +17 -17
- data/lib/generators/open_loam/install/templates/import_job.rb +38 -0
- data/lib/generators/{loam → open_loam}/install/templates/inbound_webhooks_controller.rb +4 -4
- data/lib/generators/{loam → open_loam}/install/templates/initializer.rb +87 -75
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_api_tokens.rb +15 -0
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_audit_records.rb +14 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_auth_attempts.rb → open_loam/install/templates/migrations/create_open_loam_auth_attempts.rb} +3 -3
- data/lib/generators/{loam/install/templates/migrations/create_loam_business_rules.rb → open_loam/install/templates/migrations/create_open_loam_business_rules.rb} +10 -10
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_comments.rb +13 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_configs.rb → open_loam/install/templates/migrations/create_open_loam_configs.rb} +7 -7
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_custom_field_values.rb +19 -0
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_dashboard_widgets.rb +12 -0
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_dictionaries.rb +11 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_dictionary_entries.rb → open_loam/install/templates/migrations/create_open_loam_dictionary_entries.rb} +6 -6
- data/lib/generators/{loam/install/templates/migrations/create_loam_event_deliveries.rb → open_loam/install/templates/migrations/create_open_loam_event_deliveries.rb} +5 -5
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_event_records.rb +13 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_field_definitions.rb → open_loam/install/templates/migrations/create_open_loam_field_definitions.rb} +4 -4
- data/lib/generators/{loam/install/templates/migrations/create_loam_inbound_webhooks.rb → open_loam/install/templates/migrations/create_open_loam_inbound_webhooks.rb} +9 -10
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_memberships.rb +11 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_mfa_credentials.rb → open_loam/install/templates/migrations/create_open_loam_mfa_credentials.rb} +4 -4
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_notifications.rb +15 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_pending_actions.rb → open_loam/install/templates/migrations/create_open_loam_pending_actions.rb} +9 -9
- data/lib/generators/{loam/install/templates/migrations/create_loam_perspectives.rb → open_loam/install/templates/migrations/create_open_loam_perspectives.rb} +5 -5
- data/lib/generators/{loam/install/templates/migrations/create_loam_progress_jobs.rb → open_loam/install/templates/migrations/create_open_loam_progress_jobs.rb} +5 -5
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_record_locks.rb +16 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_scheduled_jobs.rb → open_loam/install/templates/migrations/create_open_loam_scheduled_jobs.rb} +5 -5
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_search_tokens.rb +18 -0
- data/lib/generators/{loam/install/templates/migrations/create_loam_sso_providers.rb → open_loam/install/templates/migrations/create_open_loam_sso_providers.rb} +15 -12
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_tenants.rb +9 -0
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_translations.rb +15 -0
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_webhook_endpoints.rb +13 -0
- data/lib/generators/{loam → open_loam}/install/templates/migrations/create_users.rb +1 -1
- data/lib/generators/{loam → open_loam}/install/templates/user.rb +5 -5
- data/lib/generators/{loam → open_loam}/primary_key_options.rb +23 -23
- data/lib/open-loam.rb +2 -2
- data/lib/{loam → open_loam}/attachable.rb +4 -4
- data/lib/{loam → open_loam}/auditable.rb +23 -23
- data/lib/{loam → open_loam}/auth_throttle.rb +20 -12
- data/lib/{loam → open_loam}/base32.rb +1 -1
- data/lib/{loam → open_loam}/bulk.rb +7 -7
- data/lib/{loam → open_loam}/business_rules/actions.rb +7 -7
- data/lib/{loam → open_loam}/business_rules/condition.rb +5 -5
- data/lib/{loam → open_loam}/business_rules.rb +19 -19
- data/lib/{loam → open_loam}/commentable.rb +6 -6
- data/lib/{loam → open_loam}/configs.rb +19 -19
- data/lib/{loam → open_loam}/cron.rb +2 -2
- data/lib/{loam → open_loam}/csv.rb +2 -2
- data/lib/{loam → open_loam}/current.rb +5 -5
- data/lib/{loam → open_loam}/custom_field_index.rb +33 -30
- data/lib/{loam → open_loam}/custom_fields.rb +17 -17
- data/lib/{loam → open_loam}/dashboard.rb +5 -5
- data/lib/{loam → open_loam}/dictionaries.rb +12 -12
- data/lib/{loam → open_loam}/durable_events.rb +19 -19
- data/lib/{loam → open_loam}/encryptable.rb +39 -34
- data/lib/{loam → open_loam}/encryption/cipher.rb +3 -3
- data/lib/{loam → open_loam}/encryption/key_provider.rb +19 -3
- data/lib/{loam → open_loam}/encryption.rb +58 -15
- data/lib/open_loam/engine.rb +65 -0
- data/lib/{loam → open_loam}/enrichers.rb +5 -5
- data/lib/{loam → open_loam}/errors.rb +20 -10
- data/lib/{loam → open_loam}/eval.rb +2 -2
- data/lib/open_loam/event_log.rb +77 -0
- data/lib/{loam → open_loam}/event_stream.rb +14 -14
- data/lib/{loam → open_loam}/eventful.rb +12 -12
- data/lib/{loam → open_loam}/events.rb +5 -5
- data/lib/{loam → open_loam}/export.rb +12 -12
- data/lib/{loam → open_loam}/features.rb +15 -15
- data/lib/{loam → open_loam}/generated_key.rb +6 -6
- data/lib/{loam → open_loam}/import.rb +12 -15
- data/lib/{loam → open_loam}/inbound_webhooks.rb +19 -18
- data/lib/{loam → open_loam}/lifecycle.rb +54 -25
- data/lib/{loam/locales/loam.en.yml → open_loam/locales/open_loam.en.yml} +5 -5
- data/lib/{loam → open_loam}/mcp/server.rb +9 -9
- data/lib/{loam → open_loam}/mcp.rb +31 -22
- data/lib/{loam → open_loam}/notifications.rb +5 -5
- data/lib/{loam → open_loam}/open_api.rb +16 -16
- data/lib/open_loam/outbound_url.rb +100 -0
- data/lib/{loam → open_loam}/overrides.rb +10 -10
- data/lib/{loam → open_loam}/pending_actions.rb +9 -9
- data/lib/{loam → open_loam}/permissions.rb +8 -8
- data/lib/{loam → open_loam}/perspectives.rb +9 -9
- data/lib/{loam → open_loam}/policy.rb +21 -8
- data/lib/{loam → open_loam}/progress.rb +6 -6
- data/lib/{loam → open_loam}/record_locks.rb +9 -9
- data/lib/{loam → open_loam}/scheduler.rb +20 -20
- data/lib/{loam → open_loam}/search/driver.rb +3 -3
- data/lib/{loam → open_loam}/search/like_driver.rb +5 -5
- data/lib/{loam → open_loam}/search/token_driver.rb +16 -16
- data/lib/{loam → open_loam}/search.rb +5 -5
- data/lib/{loam → open_loam}/searchable.rb +22 -22
- data/lib/{loam → open_loam}/soft_deletable.rb +10 -10
- data/lib/{loam → open_loam}/sso/claims.rb +1 -1
- data/lib/{loam → open_loam}/sso/fake_provider.rb +1 -1
- data/lib/open_loam/sso/http_client.rb +51 -0
- data/lib/{loam → open_loam}/sso/oidc_provider.rb +2 -2
- data/lib/{loam → open_loam}/sso.rb +39 -18
- data/lib/{loam → open_loam}/telemetry.rb +6 -6
- data/lib/{loam → open_loam}/tenant_record.rb +10 -10
- data/lib/{loam → open_loam}/test_helpers.rb +4 -4
- data/lib/{loam → open_loam}/totp.rb +3 -3
- data/lib/{loam → open_loam}/translatable.rb +24 -24
- data/lib/{loam → open_loam}/undo.rb +11 -11
- data/lib/open_loam/version.rb +3 -0
- data/lib/{loam → open_loam}/webhooks.rb +4 -4
- data/lib/{loam → open_loam}/widgets.rb +9 -9
- data/lib/{loam → open_loam}/workflow.rb +45 -45
- data/lib/open_loam.rb +124 -0
- data/lib/tasks/open_loam.rake +203 -0
- data/lib/tasks/{loam_eval.rake → open_loam_eval.rake} +7 -7
- data/lib/tasks/open_loam_mcp.rake +20 -0
- metadata +247 -242
- data/app/models/loam/api_token.rb +0 -40
- data/app/models/loam/dictionary.rb +0 -20
- data/app/models/loam/search_token.rb +0 -9
- data/app/models/loam/sso_provider.rb +0 -35
- data/app/models/loam/tenant.rb +0 -20
- data/app/models/loam/webhook_endpoint.rb +0 -26
- data/lib/generators/loam/entity/templates/views/edit.html.erb +0 -3
- data/lib/generators/loam/entity/templates/views/index.html.erb +0 -90
- data/lib/generators/loam/entity/templates/views/new.html.erb +0 -3
- data/lib/generators/loam/entity/templates/views/show.html.erb +0 -36
- data/lib/generators/loam/install/templates/AGENTS.md +0 -341
- data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +0 -30
- data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +0 -10
- data/lib/generators/loam/install/templates/admin/history_controller.rb +0 -38
- data/lib/generators/loam/install/templates/admin/layout.html.erb +0 -76
- data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +0 -19
- data/lib/generators/loam/install/templates/api_base_controller.rb +0 -90
- data/lib/generators/loam/install/templates/import_job.rb +0 -25
- data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +0 -15
- data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +0 -14
- data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +0 -13
- data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +0 -19
- data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +0 -12
- data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +0 -11
- data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +0 -11
- data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +0 -15
- data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +0 -16
- data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +0 -18
- data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +0 -9
- data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +0 -15
- data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +0 -13
- data/lib/loam/engine.rb +0 -57
- data/lib/loam/sso/http_client.rb +0 -42
- data/lib/loam/version.rb +0 -3
- data/lib/loam.rb +0 -122
- data/lib/tasks/loam.rake +0 -171
- data/lib/tasks/loam_mcp.rake +0 -20
- /data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_edit.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/business_rules_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/configs_edit.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/configs_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/dashboard_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/dashboard_widgets_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_edit.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/dictionaries_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/features_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/field_definitions_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/imports_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/imports_preview.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/imports_summary.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/inbound_webhook_sources_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_activated.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/mfa_show.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/notifications_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/pagination.rb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/pending_actions_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_edit.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/scheduled_jobs_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/search_index.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/sessions_mfa_challenge.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/sessions_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_edit.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/sso_providers_new.html.erb +0 -0
- /data/lib/generators/{loam → open_loam}/install/templates/admin/webhook_endpoints_new.html.erb +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
module
|
|
1
|
+
module OpenLoam
|
|
2
2
|
# Field-level encryption at rest, keyed per tenant. Declare it on a model:
|
|
3
3
|
#
|
|
4
|
-
# class Patient <
|
|
5
|
-
# include
|
|
4
|
+
# class Patient < OpenLoam::TenantRecord
|
|
5
|
+
# include OpenLoam::Encryptable
|
|
6
6
|
# encrypts :ssn # encrypted at rest, not searchable
|
|
7
7
|
# encrypts :email, searchable: true # + a blind index for exact-match lookup
|
|
8
8
|
# end
|
|
@@ -11,7 +11,7 @@ module Loam
|
|
|
11
11
|
# patient.ssn # => "078-05-1120" (decrypted on read)
|
|
12
12
|
# Patient.find_by_email("a@b.com") # exact match via the blind index
|
|
13
13
|
#
|
|
14
|
-
# The value is sealed with the CURRENT tenant's key (
|
|
14
|
+
# The value is sealed with the CURRENT tenant's key (OpenLoam.tenant!), never the
|
|
15
15
|
# record's stored tenant_id — so a read in the wrong tenant's context fails the
|
|
16
16
|
# GCM auth tag instead of quietly decrypting another tenant's data. Reading or
|
|
17
17
|
# writing an encrypted field with no tenant in context raises
|
|
@@ -24,13 +24,13 @@ module Loam
|
|
|
24
24
|
extend ActiveSupport::Concern
|
|
25
25
|
|
|
26
26
|
included do
|
|
27
|
-
class_attribute :
|
|
28
|
-
class_attribute :
|
|
27
|
+
class_attribute :open_loam_encrypted_attributes, default: [].freeze, instance_writer: false
|
|
28
|
+
class_attribute :open_loam_searchable_encrypted_attributes, default: [].freeze, instance_writer: false
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
class_methods do
|
|
32
32
|
# `scope:` chooses whose key seals the field. `:tenant` (default) keys off
|
|
33
|
-
#
|
|
33
|
+
# OpenLoam.tenant! — right for entity data. A Proc `->(record) { "user/#{...}" }`
|
|
34
34
|
# keys off something else, for genuinely non-tenant data (an MFA secret
|
|
35
35
|
# belongs to the person and must decrypt in any tenant, and at login before
|
|
36
36
|
# a tenant is chosen). Non-tenant scopes cannot be `searchable`.
|
|
@@ -38,14 +38,14 @@ module Loam
|
|
|
38
38
|
name = name.to_s
|
|
39
39
|
|
|
40
40
|
if searchable && scope != :tenant
|
|
41
|
-
raise
|
|
41
|
+
raise OpenLoam::Error, "#{self.name}: `#{name}` cannot be both `searchable` and non-tenant-scoped."
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Encrypted ciphertext is meaningless to a LIKE scan, so the two are a
|
|
45
45
|
# contradiction. Caught whichever declaration comes second (Searchable
|
|
46
46
|
# runs the mirror check), so order in the model does not matter.
|
|
47
|
-
if respond_to?(:
|
|
48
|
-
raise
|
|
47
|
+
if respond_to?(:open_loam_searchable_columns) && open_loam_searchable_columns.include?(name)
|
|
48
|
+
raise OpenLoam::Error,
|
|
49
49
|
"#{self.name}: `#{name}` is declared both `searchable_by` (LIKE) and `encrypts` — " \
|
|
50
50
|
"ciphertext cannot be LIKE-searched. Drop it from `searchable_by` and use " \
|
|
51
51
|
"`encrypts :#{name}, searchable: true` for exact-match lookup instead."
|
|
@@ -54,41 +54,45 @@ module Loam
|
|
|
54
54
|
# A translation row would store the field's PLAINTEXT — recreating the
|
|
55
55
|
# leak encryption closes. Caught whichever declaration comes second
|
|
56
56
|
# (Translatable runs the mirror check), so model order does not matter.
|
|
57
|
-
if respond_to?(:
|
|
58
|
-
raise
|
|
57
|
+
if respond_to?(:open_loam_translatable_attributes) && open_loam_translatable_attributes.include?(name)
|
|
58
|
+
raise OpenLoam::Error,
|
|
59
59
|
"#{self.name}: `#{name}` is declared both `translates` and `encrypts` — a translation " \
|
|
60
60
|
"would store plaintext. Encrypted data is not translatable."
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
self.
|
|
64
|
-
self.
|
|
63
|
+
self.open_loam_encrypted_attributes = (open_loam_encrypted_attributes + [name]).freeze
|
|
64
|
+
self.open_loam_searchable_encrypted_attributes = (open_loam_searchable_encrypted_attributes + [name]).freeze if searchable
|
|
65
65
|
|
|
66
|
-
include
|
|
67
|
-
|
|
66
|
+
include open_loam_encryption_reader_writer(name, searchable, scope)
|
|
67
|
+
define_open_loam_blind_index_finder(name) if searchable
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# Reader/writer live in their own module (the
|
|
70
|
+
# Reader/writer live in their own module (the OpenLoam::Workflow precedent) so
|
|
71
71
|
# an app can override and still call `super`, and so they sit ABOVE Active
|
|
72
72
|
# Record's generated attribute methods in the ancestor chain and win.
|
|
73
|
-
def
|
|
73
|
+
def open_loam_encryption_reader_writer(name, searchable, scope)
|
|
74
74
|
hash_column = "#{name}_hash"
|
|
75
75
|
|
|
76
76
|
Module.new do
|
|
77
77
|
define_method(name) do
|
|
78
|
-
resolved =
|
|
79
|
-
aad =
|
|
80
|
-
|
|
78
|
+
resolved = open_loam_encryption_scope(scope)
|
|
79
|
+
aad = OpenLoam::Encryption.aad(resolved, self.class.table_name, name)
|
|
80
|
+
OpenLoam::Encryption.decrypt_scoped(read_attribute(name), resolved, aad: aad)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
define_method("#{name}=") do |value|
|
|
84
|
-
resolved =
|
|
84
|
+
resolved = open_loam_encryption_scope(scope)
|
|
85
85
|
# Bind this ciphertext to its (scope, table, column) so it can't be
|
|
86
86
|
# transplanted to another column/table/tenant (v2 AAD).
|
|
87
|
-
aad =
|
|
88
|
-
write_attribute(name,
|
|
87
|
+
aad = OpenLoam::Encryption.aad(resolved, self.class.table_name, name)
|
|
88
|
+
write_attribute(name, OpenLoam::Encryption.encrypt_scoped(value, resolved, aad: aad))
|
|
89
89
|
# The blind index tracks the ciphertext column: rewrite it in the
|
|
90
90
|
# same breath, so an exact-match lookup can never go stale.
|
|
91
|
-
|
|
91
|
+
if searchable
|
|
92
|
+
write_attribute(hash_column,
|
|
93
|
+
OpenLoam::Encryption.blind_index_scoped(value, resolved,
|
|
94
|
+
table: self.class.table_name, column: name))
|
|
95
|
+
end
|
|
92
96
|
end
|
|
93
97
|
end
|
|
94
98
|
end
|
|
@@ -97,11 +101,12 @@ module Loam
|
|
|
97
101
|
# deliberately shadow Active Record's dynamic `find_by_<attr>`: the obvious
|
|
98
102
|
# call must hash-and-compare, never match a plaintext query against the
|
|
99
103
|
# ciphertext column (which would silently find nothing).
|
|
100
|
-
def
|
|
101
|
-
hash_column = "#{name}_hash"
|
|
104
|
+
def define_open_loam_blind_index_finder(name)
|
|
105
|
+
hash_column = :"#{name}_hash"
|
|
102
106
|
|
|
103
107
|
define_singleton_method("where_#{name}") do |value|
|
|
104
|
-
where(hash_column =>
|
|
108
|
+
where(hash_column => OpenLoam::Encryption.blind_index(value, OpenLoam.tenant!.id,
|
|
109
|
+
table: table_name, column: name))
|
|
105
110
|
end
|
|
106
111
|
|
|
107
112
|
define_singleton_method("find_by_#{name}") do |value|
|
|
@@ -117,11 +122,11 @@ module Loam
|
|
|
117
122
|
# same safety property as an entity write); a Proc computes it from the
|
|
118
123
|
# record. A blank or `.../`-terminated result (e.g. a nil user_id) raises
|
|
119
124
|
# rather than deriving a degenerate shared key.
|
|
120
|
-
def
|
|
121
|
-
resolved = scope == :tenant ? "tenant/#{
|
|
125
|
+
def open_loam_encryption_scope(scope)
|
|
126
|
+
resolved = scope == :tenant ? "tenant/#{OpenLoam.tenant!.id}" : scope.call(self).to_s
|
|
122
127
|
|
|
123
128
|
if resolved.strip.empty? || resolved.end_with?("/")
|
|
124
|
-
raise
|
|
129
|
+
raise OpenLoam::Encryption::Error, "#{self.class}: cannot derive an encryption key from a blank scope (#{resolved.inspect})"
|
|
125
130
|
end
|
|
126
131
|
resolved
|
|
127
132
|
end
|
|
@@ -134,9 +139,9 @@ module Loam
|
|
|
134
139
|
# version tag lets old and new ciphertext coexist while this runs across a
|
|
135
140
|
# tenant's records, so rotation is a lazy re-encrypt, not a stop-the-world
|
|
136
141
|
# migration. Writes an ordinary audited "[encrypted]" update — see
|
|
137
|
-
# lib/tasks/
|
|
138
|
-
def
|
|
139
|
-
self.class.
|
|
142
|
+
# lib/tasks/open_loam.rake (open_loam:encryption:rotate).
|
|
143
|
+
def open_loam_reencrypt!
|
|
144
|
+
self.class.open_loam_encrypted_attributes.each do |name|
|
|
140
145
|
public_send("#{name}=", public_send(name))
|
|
141
146
|
end
|
|
142
147
|
save!
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
module
|
|
1
|
+
module OpenLoam
|
|
2
2
|
module Encryption
|
|
3
3
|
# AES-256-GCM sealing. GCM is *authenticated* encryption: the 16-byte auth
|
|
4
4
|
# tag turns tampering — or decrypting with the wrong key — into a loud
|
|
@@ -13,7 +13,7 @@ module Loam
|
|
|
13
13
|
# v2 binds Additional Authenticated Data (the field's tenant+table+column) into
|
|
14
14
|
# the auth tag, so a ciphertext moved to a DIFFERENT column/table/tenant fails
|
|
15
15
|
# the tag on read — it can't be transplanted. Old "v1:" rows (no AAD) keep
|
|
16
|
-
# decrypting, so upgrading is a lazy re-encrypt (
|
|
16
|
+
# decrypting, so upgrading is a lazy re-encrypt (open_loam:encryption:rotate writes
|
|
17
17
|
# v2), never a data migration. The AAD is authenticated but NOT secret — it
|
|
18
18
|
# never conceals anything, it only pins WHERE the ciphertext is allowed to live.
|
|
19
19
|
module Cipher
|
|
@@ -37,7 +37,7 @@ module Loam
|
|
|
37
37
|
"#{version}:" + [iv + tag + ciphertext].pack("m0")
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# Decrypt, or raise
|
|
40
|
+
# Decrypt, or raise OpenLoam::Encryption::DecryptionError. The wrong tenant's
|
|
41
41
|
# key, a tampered blob, a truncated tag, a v2 blob read with the WRONG (or
|
|
42
42
|
# missing) AAD, or plain garbage all fail the same loud way — never a
|
|
43
43
|
# partial or silently-wrong plaintext. A v1 blob carries no AAD, so the
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
module
|
|
1
|
+
module OpenLoam
|
|
2
2
|
module Encryption
|
|
3
3
|
# The seam a real KMS plugs into. A provider turns (scope, purpose) into a
|
|
4
4
|
# 32-byte data key; swap the default for a Vault/AWS-KMS-backed provider via
|
|
5
|
-
# `
|
|
5
|
+
# `OpenLoam::Encryption.key_provider = MyKmsProvider.new` and NO call site
|
|
6
6
|
# changes — Cipher and Encryptable only ever ask for a key.
|
|
7
7
|
#
|
|
8
8
|
# `scope` is a namespaced owner string: "tenant/5" for an entity field,
|
|
@@ -26,10 +26,26 @@ module Loam
|
|
|
26
26
|
KEY_BYTES = 32 # AES-256 and HMAC-SHA256 both take a 32-byte key
|
|
27
27
|
|
|
28
28
|
def data_key(scope:, purpose:)
|
|
29
|
+
derive(scope, purpose, OpenLoam::Encryption.master_key)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The same derivation under the key being rotated away from, so decryption
|
|
33
|
+
# can fall back to it while open_loam:encryption:rotate rewrites rows under
|
|
34
|
+
# the new one. nil when no previous key is configured.
|
|
35
|
+
def previous_data_key(scope:, purpose:)
|
|
36
|
+
previous = OpenLoam::Encryption.previous_master_key
|
|
37
|
+
return nil if previous.nil?
|
|
38
|
+
|
|
39
|
+
derive(scope, purpose, previous)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def derive(scope, purpose, master)
|
|
29
45
|
raise ArgumentError, "scope is required to derive a key" if scope.nil? || scope.to_s.empty?
|
|
30
46
|
|
|
31
47
|
OpenSSL::KDF.hkdf(
|
|
32
|
-
|
|
48
|
+
master,
|
|
33
49
|
salt: SALT,
|
|
34
50
|
# info binds the key to owner AND purpose. "tenant/5" here reproduces
|
|
35
51
|
# the pre-scope format exactly, so existing ciphertext still decrypts.
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
require "openssl"
|
|
2
|
-
require "
|
|
3
|
-
require "
|
|
2
|
+
require "open_loam/encryption/key_provider"
|
|
3
|
+
require "open_loam/encryption/cipher"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module OpenLoam
|
|
6
6
|
# Field-level encryption at rest, keyed per tenant.
|
|
7
7
|
#
|
|
8
|
-
# The facade the rest of
|
|
8
|
+
# The facade the rest of OpenLoam calls: `encrypt`/`decrypt` seal and open a value
|
|
9
9
|
# with the tenant's derived AES-256-GCM key, and `blind_index` computes the
|
|
10
10
|
# per-tenant HMAC used to find an encrypted field by exact value. Key
|
|
11
11
|
# derivation is delegated to a pluggable `key_provider` (HKDF by default, a
|
|
12
12
|
# KMS in production), so this module holds the scheme, not the key material.
|
|
13
13
|
module Encryption
|
|
14
|
-
class Error <
|
|
14
|
+
class Error < OpenLoam::Error; end
|
|
15
15
|
|
|
16
16
|
# Raised when a crypto operation is attempted with no master key configured.
|
|
17
17
|
class MissingMasterKeyError < Error
|
|
18
|
-
def initialize(msg = "
|
|
19
|
-
"`
|
|
18
|
+
def initialize(msg = "OpenLoam::Encryption has no master key. Set OPEN_LOAM_MASTER_KEY (or " \
|
|
19
|
+
"`OpenLoam::Encryption.master_key = ...`) to a high-entropy secret, e.g. " \
|
|
20
20
|
"`SecureRandom.hex(32)`. NEVER commit it; use ENV or Rails credentials.")
|
|
21
21
|
super
|
|
22
22
|
end
|
|
@@ -41,19 +41,32 @@ module Loam
|
|
|
41
41
|
@master_key = value
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
attr_writer :previous_master_key
|
|
45
|
+
|
|
46
|
+
# The key being rotated AWAY from. Set it alongside the new master and
|
|
47
|
+
# decryption falls back to it, which is what makes rotation possible at
|
|
48
|
+
# all: open_loam:encryption:rotate has to READ every row under the old key
|
|
49
|
+
# before it can rewrite it under the new one. Unset it once the rotation
|
|
50
|
+
# has run everywhere.
|
|
51
|
+
def previous_master_key
|
|
52
|
+
key = defined?(@previous_master_key) ? @previous_master_key : nil
|
|
53
|
+
key ||= ENV["OPEN_LOAM_PREVIOUS_MASTER_KEY"]
|
|
54
|
+
key.presence
|
|
55
|
+
end
|
|
56
|
+
|
|
44
57
|
def master_key
|
|
45
|
-
key = @master_key || ENV["
|
|
58
|
+
key = @master_key || ENV["OPEN_LOAM_MASTER_KEY"]
|
|
46
59
|
raise MissingMasterKeyError if key.nil? || key.empty?
|
|
47
60
|
if key.bytesize < MASTER_KEY_MIN_BYTES
|
|
48
61
|
raise MissingMasterKeyError,
|
|
49
|
-
"
|
|
62
|
+
"OPEN_LOAM_MASTER_KEY is too short (#{key.bytesize} bytes); use at least " \
|
|
50
63
|
"#{MASTER_KEY_MIN_BYTES}, e.g. `SecureRandom.hex(32)`."
|
|
51
64
|
end
|
|
52
65
|
key
|
|
53
66
|
end
|
|
54
67
|
|
|
55
68
|
# Tenant-scoped operations — the default for entity fields via
|
|
56
|
-
#
|
|
69
|
+
# OpenLoam::Encryptable. nil stays nil (an unset field is not "the empty
|
|
57
70
|
# string encrypted"); any other value is stringified and sealed.
|
|
58
71
|
def encrypt(plaintext, tenant_id)
|
|
59
72
|
encrypt_scoped(plaintext, tenant_scope(tenant_id))
|
|
@@ -68,8 +81,8 @@ module Loam
|
|
|
68
81
|
# hash) — the accepted trade-off for searchability — but the per-tenant
|
|
69
82
|
# HMAC key means the same value hashes differently across tenants, so
|
|
70
83
|
# equality never leaks between them. Only searchable fields get one.
|
|
71
|
-
def blind_index(value, tenant_id)
|
|
72
|
-
blind_index_scoped(value, tenant_scope(tenant_id))
|
|
84
|
+
def blind_index(value, tenant_id, table: nil, column: nil)
|
|
85
|
+
blind_index_scoped(value, tenant_scope(tenant_id), table: table, column: column)
|
|
73
86
|
end
|
|
74
87
|
|
|
75
88
|
# Explicit-scope variants, for data owned by something OTHER than a tenant
|
|
@@ -82,23 +95,44 @@ module Loam
|
|
|
82
95
|
|
|
83
96
|
def decrypt_scoped(payload, scope, aad: nil)
|
|
84
97
|
return nil if payload.nil?
|
|
98
|
+
|
|
85
99
|
Cipher.open(payload, data_key(scope, :encryption), aad: aad)
|
|
100
|
+
rescue DecryptionError
|
|
101
|
+
# GCM's auth tag makes "wrong key" a loud, unambiguous failure, so
|
|
102
|
+
# falling back is safe: a blob that opens under the previous key really
|
|
103
|
+
# was sealed with it. Writes always use the CURRENT key, so a row is
|
|
104
|
+
# rotated the moment anything saves it.
|
|
105
|
+
previous = previous_data_key(scope, :encryption)
|
|
106
|
+
raise if previous.nil?
|
|
107
|
+
|
|
108
|
+
Cipher.open(payload, previous, aad: aad)
|
|
86
109
|
end
|
|
87
110
|
|
|
88
111
|
# The Additional Authenticated Data that BINDS a ciphertext to where it
|
|
89
112
|
# lives — the key scope (tenant/owner) + table + column. Reconstructed
|
|
90
113
|
# identically on read and write, so a blob moved to a different column,
|
|
91
114
|
# table, or tenant fails the auth tag. NOT the record id (see
|
|
92
|
-
#
|
|
115
|
+
# OpenLoam::Encryptable): the id is unknown at INSERT time, and binding it would
|
|
93
116
|
# force an ugly post-insert double-write; record-swap within one
|
|
94
117
|
# tenant+table+column stays a documented residual.
|
|
95
118
|
def aad(scope, table, column)
|
|
96
119
|
"loam-aad:v2:#{scope}:#{table}:#{column}"
|
|
97
120
|
end
|
|
98
121
|
|
|
99
|
-
|
|
122
|
+
# The key is bound to (scope, table, column) for the same reason the
|
|
123
|
+
# ciphertext AAD is. A key scoped only to the tenant makes one value hash
|
|
124
|
+
# identically in every searchable column in that tenant: a dump correlates
|
|
125
|
+
# rows across tables, and anyone who can write one such field gets an
|
|
126
|
+
# equality oracle against columns they cannot read.
|
|
127
|
+
#
|
|
128
|
+
# table/column default to nil so an unbound caller still works — the
|
|
129
|
+
# per-tenant key, which is what OpenLoam::PendingActions wants for an
|
|
130
|
+
# idempotency digest that is not a column at all.
|
|
131
|
+
def blind_index_scoped(value, scope, table: nil, column: nil)
|
|
100
132
|
return nil if value.nil?
|
|
101
|
-
|
|
133
|
+
|
|
134
|
+
purpose = table && column ? "blind_index/#{table}/#{column}" : :blind_index
|
|
135
|
+
OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, purpose), value.to_s)
|
|
102
136
|
end
|
|
103
137
|
|
|
104
138
|
private
|
|
@@ -120,6 +154,15 @@ module Loam
|
|
|
120
154
|
|
|
121
155
|
key_provider.data_key(scope: scope, purpose: purpose)
|
|
122
156
|
end
|
|
157
|
+
|
|
158
|
+
# nil unless a previous master is configured AND the provider supports the
|
|
159
|
+
# fallback — a KMS-backed provider manages its own key versions, so the
|
|
160
|
+
# base KeyProvider answers nil and nothing changes for it.
|
|
161
|
+
def previous_data_key(scope, purpose)
|
|
162
|
+
return nil unless key_provider.respond_to?(:previous_data_key)
|
|
163
|
+
|
|
164
|
+
key_provider.previous_data_key(scope: scope, purpose: purpose)
|
|
165
|
+
end
|
|
123
166
|
end
|
|
124
167
|
end
|
|
125
168
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module OpenLoam
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
# Not isolated on purpose: OpenLoam models live under the OpenLoam:: namespace but
|
|
4
|
+
# share the host app's routes/helpers, keeping the prototype surface small.
|
|
5
|
+
|
|
6
|
+
# Webhook dispatch listens to every OpenLoam event. Wired after initialization
|
|
7
|
+
# so the models it queries are loadable, and guarded against subscribing
|
|
8
|
+
# twice (see OpenLoam::Webhooks.subscribe!).
|
|
9
|
+
config.after_initialize do
|
|
10
|
+
OpenLoam::Webhooks.subscribe!
|
|
11
|
+
OpenLoam::DurableEvents.subscribe! # persist + retry durable subscribers (L-706)
|
|
12
|
+
OpenLoam::EventLog.subscribe! # capture every event as queryable history (L-204)
|
|
13
|
+
OpenLoam::BusinessRules.subscribe!
|
|
14
|
+
OpenLoam::Widgets.register_builtins! # the default dashboard widgets
|
|
15
|
+
OpenLoam::Overrides.check! # warn about any stale disable/replace overrides
|
|
16
|
+
|
|
17
|
+
# The durability sweep runs per-tenant on a schedule (materialized into
|
|
18
|
+
# each tenant by OpenLoam::Scheduler.sync_tenant). interval:300 = every 5 min.
|
|
19
|
+
OpenLoam::Scheduler.register(
|
|
20
|
+
key: OpenLoam::DurableEvents::SWEEP_KEY, name: "Event redelivery sweep",
|
|
21
|
+
job_class: "OpenLoam::EventRedeliverySweepJob", schedule: "interval:300", scope: "tenant"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Event-log retention, also per-tenant. interval:86400 = daily; the window
|
|
25
|
+
# itself is OpenLoam.event_log_retention.
|
|
26
|
+
OpenLoam::Scheduler.register(
|
|
27
|
+
key: OpenLoam::EventLog::PRUNE_KEY, name: "Event log prune",
|
|
28
|
+
job_class: "OpenLoam::EventLogPruneJob", schedule: "interval:86400", scope: "tenant"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# OpenLoam's Active Record layer — TenantRecord, Auditable, Eventful and the
|
|
33
|
+
# rest — is defined inside ActiveSupport.on_load(:active_record) in
|
|
34
|
+
# lib/open_loam.rb, so those constants exist only once something has referenced
|
|
35
|
+
# ActiveRecord::Base.
|
|
36
|
+
#
|
|
37
|
+
# Zeitwerk eager-loads app/models alphabetically. A host model that inherits
|
|
38
|
+
# from OpenLoam::TenantRecord and sorts ahead of application_record.rb reaches
|
|
39
|
+
# the constant before anything has touched Base, and eager load dies with
|
|
40
|
+
# "uninitialized constant OpenLoam::TenantRecord". Whether an app hits it
|
|
41
|
+
# depends on its model names, so it shows up on a rename rather than on the
|
|
42
|
+
# change that caused it.
|
|
43
|
+
#
|
|
44
|
+
# Referencing Base here fires the hook while load order is still the
|
|
45
|
+
# engine's to decide instead of each host app's. :eager_load! is late enough
|
|
46
|
+
# that every active_record.* initializer has applied its configuration, and
|
|
47
|
+
# it runs in every environment (in development it is a no-op that still
|
|
48
|
+
# executes), so lazily-loaded apps are settled by the same line.
|
|
49
|
+
initializer "open_loam.active_record_layer", before: :eager_load! do
|
|
50
|
+
ActiveRecord::Base
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# OpenLoam's own UI strings ship under lib/ (the gemspec packages lib/**/*), so
|
|
54
|
+
# register them on the app's I18n load path explicitly rather than relying on
|
|
55
|
+
# the default config/locales path (which the gem does not ship). An app
|
|
56
|
+
# overrides any key with its own config/locales/open_loam.<locale>.yml.
|
|
57
|
+
initializer "open_loam.i18n" do |app|
|
|
58
|
+
app.config.i18n.load_path += Dir[File.expand_path("locales/*.yml", __dir__)]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# lib/tasks/open_loam.rake (bin/rails open_loam:sync) is picked up by Rails::Engine's
|
|
62
|
+
# own lib/tasks loading. Loading it again from a `rake_tasks` block here
|
|
63
|
+
# would define the task twice and run its body twice.
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
module
|
|
1
|
+
module OpenLoam
|
|
2
2
|
# Response enrichers: one module attaches a computed block onto ANOTHER
|
|
3
3
|
# module's entity in admin/API responses, with no foreign-key coupling.
|
|
4
4
|
#
|
|
5
5
|
# # billing knows about equipment; equipment knows nothing about billing:
|
|
6
|
-
#
|
|
6
|
+
# OpenLoam::Enrichers.register("Equipment", key: "outstanding_balance") do |equipment|
|
|
7
7
|
# Invoice.where(equipment_id: equipment.id).sum(:balance)
|
|
8
8
|
# end
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# OpenLoam::Enrichers.enrich(equipment) # => { "outstanding_balance" => 1200 }
|
|
11
|
+
# OpenLoam::Enrichers.enrich_many(equipments) # => { id => { key => value }, ... }
|
|
12
12
|
#
|
|
13
13
|
# Distinct from custom fields: a custom field is STORED on the record; an
|
|
14
14
|
# enricher is COMPUTED at read time by different (possibly cross-module) code.
|
|
@@ -97,7 +97,7 @@ module Loam
|
|
|
97
97
|
|
|
98
98
|
def warn_failure(enricher, error)
|
|
99
99
|
Rails.logger&.warn(
|
|
100
|
-
"[
|
|
100
|
+
"[OpenLoam::Enrichers] #{enricher.entity_type}/#{enricher.key} raised #{error.class}: #{error.message} — key omitted"
|
|
101
101
|
)
|
|
102
102
|
end
|
|
103
103
|
end
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
module
|
|
1
|
+
module OpenLoam
|
|
2
2
|
class Error < StandardError; end
|
|
3
3
|
|
|
4
4
|
# Raised whenever a tenant-scoped model is touched with no tenant in
|
|
5
|
-
#
|
|
5
|
+
# OpenLoam::Current. This is a structural guardrail, not a convention: forgetting
|
|
6
6
|
# the tenant context fails loudly (in tests, before it leaks) instead of
|
|
7
7
|
# silently returning or writing cross-tenant data.
|
|
8
8
|
class MissingTenantError < Error
|
|
9
|
-
def initialize(msg = "No tenant set in
|
|
9
|
+
def initialize(msg = "No tenant set in OpenLoam::Current — wrap this call in OpenLoam.as_tenant(tenant) { ... }")
|
|
10
10
|
super
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# Raised by admin controllers / callers when a policy check fails, and by a
|
|
15
|
-
#
|
|
15
|
+
# OpenLoam::Workflow transition the current actor's role may not perform.
|
|
16
16
|
class NotAuthorizedError < Error; end
|
|
17
17
|
|
|
18
|
-
# Raised when a
|
|
18
|
+
# Raised when a OpenLoam::Workflow transition is attempted from a state it does
|
|
19
19
|
# not move from ("approve a report that was never submitted"). Like every
|
|
20
|
-
#
|
|
20
|
+
# OpenLoam guardrail it fails at the call site rather than writing a state the
|
|
21
21
|
# machine says is impossible.
|
|
22
22
|
class InvalidTransitionError < Error; end
|
|
23
23
|
|
|
24
24
|
# Raised when an event name does not follow the `domain.thing.happened` convention.
|
|
25
25
|
class InvalidEventNameError < Error; end
|
|
26
26
|
|
|
27
|
-
# Raised by
|
|
28
|
-
# has no matching
|
|
27
|
+
# Raised by OpenLoam::CustomFields#custom_field/#set_custom_field when the name
|
|
28
|
+
# has no matching OpenLoam::FieldDefinition for this tenant + entity. Fails
|
|
29
29
|
# loudly at the access site rather than silently reading/writing nil.
|
|
30
30
|
class UnknownCustomFieldError < Error; end
|
|
31
31
|
|
|
@@ -36,12 +36,22 @@ module Loam
|
|
|
36
36
|
class FeatureDisabledError < Error; end
|
|
37
37
|
|
|
38
38
|
# Raised when a filter/sort is attempted on a custom field the current role may
|
|
39
|
-
# not read (
|
|
39
|
+
# not read (OpenLoam::CustomFieldIndex) — otherwise a filter would be an inference
|
|
40
40
|
# oracle on a restricted field. A NotAuthorizedError so admin controllers render
|
|
41
41
|
# it as 403, like any other field-access denial.
|
|
42
42
|
class FieldAccessError < NotAuthorizedError; end
|
|
43
43
|
|
|
44
|
-
# Raised when a
|
|
44
|
+
# Raised when a controller action finishes without having authorized anything
|
|
45
|
+
# (see the `verify_authorized!` guard in the generated base controllers).
|
|
46
|
+
#
|
|
47
|
+
# Deliberately NOT a NotAuthorizedError: that one means "this person may not",
|
|
48
|
+
# and the base controllers render it as a tidy 403. This means "this code
|
|
49
|
+
# forgot to ask", which is a developer bug that must not be dressed up as a
|
|
50
|
+
# refusal — nothing rescues it, so it surfaces as a 500 in dev and a failure
|
|
51
|
+
# in tests.
|
|
52
|
+
class AuthorizationNotPerformedError < Error; end
|
|
53
|
+
|
|
54
|
+
# Raised when a OpenLoam::BusinessRule with a `block_transition` action vetoes a
|
|
45
55
|
# workflow transition. Distinct from NotAuthorizedError (a role gate) and
|
|
46
56
|
# InvalidTransitionError (an illegal move): the move is legal and permitted,
|
|
47
57
|
# but a rule says "not under these conditions".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
module
|
|
1
|
+
module OpenLoam
|
|
2
2
|
# Scripted evaluation scoring for the golden tasks (L-303). An agent (or a
|
|
3
|
-
# human) implements a task against a fresh
|
|
3
|
+
# human) implements a task against a fresh OpenLoam app; this scores the RESULT
|
|
4
4
|
# consistently — the test outcome plus the structural invariants — into a
|
|
5
5
|
# machine-readable scorecard, so runs are comparable over time
|
|
6
6
|
# (ai/benchmark_runs/). Driving the agent is out of scope; consistent scoring
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module OpenLoam
|
|
2
|
+
# Captures every published event as a queryable OpenLoam::EventRecord row —
|
|
3
|
+
# the capture half of the event system, where OpenLoam::DurableEvents is the
|
|
4
|
+
# delivery half ("durability is of DELIVERY, not CAPTURE").
|
|
5
|
+
#
|
|
6
|
+
# Capture is ON by default and captures everything but OpenLoam.uncaptured_events.
|
|
7
|
+
# That inverts OpenLoam.broadcast_events on purpose; see ADR 0007 for why, and
|
|
8
|
+
# for why this stayed in-gem rather than adopting Rails Event Store.
|
|
9
|
+
#
|
|
10
|
+
# Capture runs INLINE in the publisher's thread, so a failed insert propagates
|
|
11
|
+
# into whatever published the event. Deliberate — do not rescue it. Swallowing
|
|
12
|
+
# the error re-opens the silent gap this module exists to close.
|
|
13
|
+
module EventLog
|
|
14
|
+
PRUNE_KEY = "open_loam_event_log_prune".freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Idempotent: subscribing twice would capture every event twice.
|
|
18
|
+
def subscribe!
|
|
19
|
+
@subscription ||= OpenLoam::Events.subscribe_all { |event_name, payload| capture(event_name, payload) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Exclusion is the only knob, because pattern_matches? has no match-all
|
|
23
|
+
# spelling ("" is an exact match on the empty name). Do not add an
|
|
24
|
+
# allow-list: capture-all has to stay the absence of configuration.
|
|
25
|
+
def capturable?(event_name)
|
|
26
|
+
OpenLoam.uncaptured_events.none? do |pattern|
|
|
27
|
+
next false if OpenLoam::Overrides.disabled?(:uncaptured_events, pattern) # an app can re-enable a default exclusion
|
|
28
|
+
|
|
29
|
+
OpenLoam::Events.pattern_matches?(pattern, event_name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def capture(event_name, payload)
|
|
34
|
+
return unless capturable?(event_name)
|
|
35
|
+
|
|
36
|
+
tenant = OpenLoam::Tenant.find_by(id: payload[:tenant_id])
|
|
37
|
+
return if tenant.nil? # nil-tenant events are not captured (as Webhooks.dispatch / DurableEvents.capture)
|
|
38
|
+
|
|
39
|
+
OpenLoam.as_tenant(tenant) do
|
|
40
|
+
OpenLoam::EventRecord.create!(
|
|
41
|
+
name: event_name.to_s,
|
|
42
|
+
payload: payload.transform_keys(&:to_s),
|
|
43
|
+
occurred_at: Time.current
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# name_or_prefix takes an exact event name or a trailing-dot domain
|
|
49
|
+
# prefix, the same rule as Events.subscribe. Oldest first.
|
|
50
|
+
def read(name_or_prefix = nil, since: nil, limit: nil)
|
|
51
|
+
scope = OpenLoam::EventRecord.chronological
|
|
52
|
+
scope = scope.matching(name_or_prefix) if name_or_prefix.present?
|
|
53
|
+
scope = scope.where(occurred_at: since..) if since
|
|
54
|
+
scope = scope.limit(limit) if limit
|
|
55
|
+
scope
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A re-read of history, NOT a second publish: nothing else on the bus fires
|
|
59
|
+
# and a replayed event is not re-captured. Handlers must be idempotent, and
|
|
60
|
+
# get string payload keys (from the row) where a live subscriber got symbols.
|
|
61
|
+
def replay(name_or_prefix = nil, since: nil, limit: nil, &handler)
|
|
62
|
+
read(name_or_prefix, since: since, limit: limit).each do |record|
|
|
63
|
+
handler.call(record.name, record.payload_hash)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# delete_all, NOT destroy_all: rows are readonly once persisted, and
|
|
68
|
+
# destroy on a readonly record raises ActiveRecord::ReadOnlyRecord.
|
|
69
|
+
def prune(now: Time.current)
|
|
70
|
+
retention = OpenLoam.event_log_retention
|
|
71
|
+
return 0 if retention.nil?
|
|
72
|
+
|
|
73
|
+
OpenLoam::EventRecord.where(occurred_at: ...(now - retention)).delete_all
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|