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,145 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Field-level encryption at rest, keyed per tenant. Declare it on a model:
|
|
3
|
+
#
|
|
4
|
+
# class Patient < Loam::TenantRecord
|
|
5
|
+
# include Loam::Encryptable
|
|
6
|
+
# encrypts :ssn # encrypted at rest, not searchable
|
|
7
|
+
# encrypts :email, searchable: true # + a blind index for exact-match lookup
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Patient.create!(ssn: "078-05-1120") # the ssn COLUMN now holds "v1:...."
|
|
11
|
+
# patient.ssn # => "078-05-1120" (decrypted on read)
|
|
12
|
+
# Patient.find_by_email("a@b.com") # exact match via the blind index
|
|
13
|
+
#
|
|
14
|
+
# The value is sealed with the CURRENT tenant's key (Loam.tenant!), never the
|
|
15
|
+
# record's stored tenant_id — so a read in the wrong tenant's context fails the
|
|
16
|
+
# GCM auth tag instead of quietly decrypting another tenant's data. Reading or
|
|
17
|
+
# writing an encrypted field with no tenant in context raises
|
|
18
|
+
# MissingTenantError: you cannot encrypt without knowing whose key.
|
|
19
|
+
#
|
|
20
|
+
# Encryption happens eagerly on assignment, so re-submitting a form with the
|
|
21
|
+
# same value re-seals it under a fresh IV and records a "[encrypted]" audit
|
|
22
|
+
# update with no real change — accepted prototype noise.
|
|
23
|
+
module Encryptable
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
|
|
26
|
+
included do
|
|
27
|
+
class_attribute :loam_encrypted_attributes, default: [].freeze, instance_writer: false
|
|
28
|
+
class_attribute :loam_searchable_encrypted_attributes, default: [].freeze, instance_writer: false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class_methods do
|
|
32
|
+
# `scope:` chooses whose key seals the field. `:tenant` (default) keys off
|
|
33
|
+
# Loam.tenant! — right for entity data. A Proc `->(record) { "user/#{...}" }`
|
|
34
|
+
# keys off something else, for genuinely non-tenant data (an MFA secret
|
|
35
|
+
# belongs to the person and must decrypt in any tenant, and at login before
|
|
36
|
+
# a tenant is chosen). Non-tenant scopes cannot be `searchable`.
|
|
37
|
+
def encrypts(name, searchable: false, scope: :tenant)
|
|
38
|
+
name = name.to_s
|
|
39
|
+
|
|
40
|
+
if searchable && scope != :tenant
|
|
41
|
+
raise Loam::Error, "#{self.name}: `#{name}` cannot be both `searchable` and non-tenant-scoped."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Encrypted ciphertext is meaningless to a LIKE scan, so the two are a
|
|
45
|
+
# contradiction. Caught whichever declaration comes second (Searchable
|
|
46
|
+
# runs the mirror check), so order in the model does not matter.
|
|
47
|
+
if respond_to?(:loam_searchable_columns) && loam_searchable_columns.include?(name)
|
|
48
|
+
raise Loam::Error,
|
|
49
|
+
"#{self.name}: `#{name}` is declared both `searchable_by` (LIKE) and `encrypts` — " \
|
|
50
|
+
"ciphertext cannot be LIKE-searched. Drop it from `searchable_by` and use " \
|
|
51
|
+
"`encrypts :#{name}, searchable: true` for exact-match lookup instead."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A translation row would store the field's PLAINTEXT — recreating the
|
|
55
|
+
# leak encryption closes. Caught whichever declaration comes second
|
|
56
|
+
# (Translatable runs the mirror check), so model order does not matter.
|
|
57
|
+
if respond_to?(:loam_translatable_attributes) && loam_translatable_attributes.include?(name)
|
|
58
|
+
raise Loam::Error,
|
|
59
|
+
"#{self.name}: `#{name}` is declared both `translates` and `encrypts` — a translation " \
|
|
60
|
+
"would store plaintext. Encrypted data is not translatable."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
self.loam_encrypted_attributes = (loam_encrypted_attributes + [name]).freeze
|
|
64
|
+
self.loam_searchable_encrypted_attributes = (loam_searchable_encrypted_attributes + [name]).freeze if searchable
|
|
65
|
+
|
|
66
|
+
include loam_encryption_reader_writer(name, searchable, scope)
|
|
67
|
+
define_loam_blind_index_finder(name) if searchable
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reader/writer live in their own module (the Loam::Workflow precedent) so
|
|
71
|
+
# an app can override and still call `super`, and so they sit ABOVE Active
|
|
72
|
+
# Record's generated attribute methods in the ancestor chain and win.
|
|
73
|
+
def loam_encryption_reader_writer(name, searchable, scope)
|
|
74
|
+
hash_column = "#{name}_hash"
|
|
75
|
+
|
|
76
|
+
Module.new do
|
|
77
|
+
define_method(name) do
|
|
78
|
+
resolved = loam_encryption_scope(scope)
|
|
79
|
+
aad = Loam::Encryption.aad(resolved, self.class.table_name, name)
|
|
80
|
+
Loam::Encryption.decrypt_scoped(read_attribute(name), resolved, aad: aad)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
define_method("#{name}=") do |value|
|
|
84
|
+
resolved = loam_encryption_scope(scope)
|
|
85
|
+
# Bind this ciphertext to its (scope, table, column) so it can't be
|
|
86
|
+
# transplanted to another column/table/tenant (v2 AAD).
|
|
87
|
+
aad = Loam::Encryption.aad(resolved, self.class.table_name, name)
|
|
88
|
+
write_attribute(name, Loam::Encryption.encrypt_scoped(value, resolved, aad: aad))
|
|
89
|
+
# The blind index tracks the ciphertext column: rewrite it in the
|
|
90
|
+
# same breath, so an exact-match lookup can never go stale.
|
|
91
|
+
write_attribute(hash_column, Loam::Encryption.blind_index_scoped(value, resolved)) if searchable
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `find_by_<name>` / `where_<name>` match the per-tenant blind index. They
|
|
97
|
+
# deliberately shadow Active Record's dynamic `find_by_<attr>`: the obvious
|
|
98
|
+
# call must hash-and-compare, never match a plaintext query against the
|
|
99
|
+
# ciphertext column (which would silently find nothing).
|
|
100
|
+
def define_loam_blind_index_finder(name)
|
|
101
|
+
hash_column = "#{name}_hash"
|
|
102
|
+
|
|
103
|
+
define_singleton_method("where_#{name}") do |value|
|
|
104
|
+
where(hash_column => Loam::Encryption.blind_index(value, Loam.tenant!.id))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
define_singleton_method("find_by_#{name}") do |value|
|
|
108
|
+
public_send("where_#{name}", value).first
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Resolve a declared `scope:` to the namespaced owner string the key is
|
|
116
|
+
# derived from. `:tenant` keys off the current tenant (raises with none, the
|
|
117
|
+
# same safety property as an entity write); a Proc computes it from the
|
|
118
|
+
# record. A blank or `.../`-terminated result (e.g. a nil user_id) raises
|
|
119
|
+
# rather than deriving a degenerate shared key.
|
|
120
|
+
def loam_encryption_scope(scope)
|
|
121
|
+
resolved = scope == :tenant ? "tenant/#{Loam.tenant!.id}" : scope.call(self).to_s
|
|
122
|
+
|
|
123
|
+
if resolved.strip.empty? || resolved.end_with?("/")
|
|
124
|
+
raise Loam::Encryption::Error, "#{self.class}: cannot derive an encryption key from a blank scope (#{resolved.inspect})"
|
|
125
|
+
end
|
|
126
|
+
resolved
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
public
|
|
130
|
+
|
|
131
|
+
# Re-seal every encrypted field under the current key, with fresh IVs — the
|
|
132
|
+
# per-record step of a key rotation (read old, write new). With HKDF-from-
|
|
133
|
+
# master, a real rotation means a new master or a bumped Cipher::VERSION; the
|
|
134
|
+
# version tag lets old and new ciphertext coexist while this runs across a
|
|
135
|
+
# tenant's records, so rotation is a lazy re-encrypt, not a stop-the-world
|
|
136
|
+
# migration. Writes an ordinary audited "[encrypted]" update — see
|
|
137
|
+
# lib/tasks/loam.rake (loam:encryption:rotate).
|
|
138
|
+
def loam_reencrypt!
|
|
139
|
+
self.class.loam_encrypted_attributes.each do |name|
|
|
140
|
+
public_send("#{name}=", public_send(name))
|
|
141
|
+
end
|
|
142
|
+
save!
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module Encryption
|
|
3
|
+
# AES-256-GCM sealing. GCM is *authenticated* encryption: the 16-byte auth
|
|
4
|
+
# tag turns tampering — or decrypting with the wrong key — into a loud
|
|
5
|
+
# failure on open, never silent garbage.
|
|
6
|
+
#
|
|
7
|
+
# Stored format, one string column:
|
|
8
|
+
#
|
|
9
|
+
# "v1:" + base64( iv[12] ++ auth_tag[16] ++ ciphertext ) (no AAD)
|
|
10
|
+
# "v2:" + base64( iv[12] ++ auth_tag[16] ++ ciphertext ) sealed WITH AAD
|
|
11
|
+
#
|
|
12
|
+
# The version tag lets the scheme evolve without a stop-the-world migration:
|
|
13
|
+
# v2 binds Additional Authenticated Data (the field's tenant+table+column) into
|
|
14
|
+
# the auth tag, so a ciphertext moved to a DIFFERENT column/table/tenant fails
|
|
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 (loam:encryption:rotate writes
|
|
17
|
+
# v2), never a data migration. The AAD is authenticated but NOT secret — it
|
|
18
|
+
# never conceals anything, it only pins WHERE the ciphertext is allowed to live.
|
|
19
|
+
module Cipher
|
|
20
|
+
VERSION = "v1".freeze # legacy, no AAD — still readable
|
|
21
|
+
V2 = "v2".freeze # current writes — AAD-bound
|
|
22
|
+
IV_BYTES = 12 # GCM's standard nonce size
|
|
23
|
+
TAG_BYTES = 16 # full-length GCM tag; a shorter tag weakens authentication
|
|
24
|
+
|
|
25
|
+
# Encrypt with a fresh random IV. Reusing an IV under one key is
|
|
26
|
+
# catastrophic for GCM, so the IV is never derived or fixed — always
|
|
27
|
+
# OpenSSL's CSPRNG, once per value. With an `aad:` the ciphertext is bound
|
|
28
|
+
# to that context (v2); without one it stays v1 (a bare tenant-scoped blob).
|
|
29
|
+
def self.seal(plaintext, key, aad: nil)
|
|
30
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
|
|
31
|
+
cipher.key = key
|
|
32
|
+
iv = cipher.random_iv
|
|
33
|
+
version = aad ? V2 : VERSION
|
|
34
|
+
cipher.auth_data = aad if aad # folded into the tag, not encrypted
|
|
35
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
36
|
+
tag = cipher.auth_tag(TAG_BYTES)
|
|
37
|
+
"#{version}:" + [iv + tag + ciphertext].pack("m0")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Decrypt, or raise Loam::Encryption::DecryptionError. The wrong tenant's
|
|
41
|
+
# key, a tampered blob, a truncated tag, a v2 blob read with the WRONG (or
|
|
42
|
+
# missing) AAD, or plain garbage all fail the same loud way — never a
|
|
43
|
+
# partial or silently-wrong plaintext. A v1 blob carries no AAD, so the
|
|
44
|
+
# passed `aad:` is ignored for it (backward compatible).
|
|
45
|
+
def self.open(payload, key, aad: nil)
|
|
46
|
+
version, blob = payload.to_s.split(":", 2)
|
|
47
|
+
raise DecryptionError, "unrecognized ciphertext format" unless [ VERSION, V2 ].include?(version) && blob
|
|
48
|
+
|
|
49
|
+
raw = blob.unpack1("m0")
|
|
50
|
+
# Enforce the full IV+tag length BEFORE slicing: OpenSSL will verify a
|
|
51
|
+
# truncated tag against a truncated blob, so a short payload must be
|
|
52
|
+
# rejected here, not handed to the cipher.
|
|
53
|
+
raise DecryptionError, "ciphertext too short" if raw.nil? || raw.bytesize < IV_BYTES + TAG_BYTES
|
|
54
|
+
|
|
55
|
+
iv = raw.byteslice(0, IV_BYTES)
|
|
56
|
+
tag = raw.byteslice(IV_BYTES, TAG_BYTES)
|
|
57
|
+
ciphertext = raw.byteslice(IV_BYTES + TAG_BYTES..) || ""
|
|
58
|
+
|
|
59
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
|
|
60
|
+
cipher.key = key
|
|
61
|
+
cipher.iv = iv
|
|
62
|
+
cipher.auth_tag = tag
|
|
63
|
+
cipher.auth_data = aad if version == V2 && aad # v2 rows require the matching AAD
|
|
64
|
+
plaintext = cipher.update(ciphertext) + cipher.final
|
|
65
|
+
# Decryption yields ASCII-8BIT bytes; our columns hold UTF-8 text.
|
|
66
|
+
plaintext.force_encoding(Encoding::UTF_8)
|
|
67
|
+
rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError
|
|
68
|
+
# $! is preserved as the DecryptionError's `cause`. The message stays
|
|
69
|
+
# deliberately vague — it must not distinguish "wrong key" from
|
|
70
|
+
# "corrupt data" to a caller.
|
|
71
|
+
raise DecryptionError, "could not decrypt (wrong key or corrupt data)"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module Encryption
|
|
3
|
+
# The seam a real KMS plugs into. A provider turns (scope, purpose) into a
|
|
4
|
+
# 32-byte data key; swap the default for a Vault/AWS-KMS-backed provider via
|
|
5
|
+
# `Loam::Encryption.key_provider = MyKmsProvider.new` and NO call site
|
|
6
|
+
# changes — Cipher and Encryptable only ever ask for a key.
|
|
7
|
+
#
|
|
8
|
+
# `scope` is a namespaced owner string: "tenant/5" for an entity field,
|
|
9
|
+
# "user/12" for genuinely user-owned data (an MFA secret) that must decrypt
|
|
10
|
+
# regardless of which tenant the user is currently acting in.
|
|
11
|
+
class KeyProvider
|
|
12
|
+
def data_key(scope:, purpose:)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #data_key(scope:, purpose:)"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Default provider: derive a per-scope, per-purpose key from one master key
|
|
18
|
+
# with HKDF-SHA256. Deterministic, so no key needs to be stored, and one
|
|
19
|
+
# scope's key can NEVER equal another's because the scope is bound into the
|
|
20
|
+
# HKDF `info`. Purpose separation means the encryption key and the
|
|
21
|
+
# blind-index (HMAC) key derived for one scope are independent.
|
|
22
|
+
class HkdfKeyProvider < KeyProvider
|
|
23
|
+
# A fixed, non-secret salt. HKDF's strength comes from the master key's
|
|
24
|
+
# entropy; the salt only has to be stable so derivation is reproducible.
|
|
25
|
+
SALT = "loam.encryption.hkdf.v1".freeze
|
|
26
|
+
KEY_BYTES = 32 # AES-256 and HMAC-SHA256 both take a 32-byte key
|
|
27
|
+
|
|
28
|
+
def data_key(scope:, purpose:)
|
|
29
|
+
raise ArgumentError, "scope is required to derive a key" if scope.nil? || scope.to_s.empty?
|
|
30
|
+
|
|
31
|
+
OpenSSL::KDF.hkdf(
|
|
32
|
+
Loam::Encryption.master_key,
|
|
33
|
+
salt: SALT,
|
|
34
|
+
# info binds the key to owner AND purpose. "tenant/5" here reproduces
|
|
35
|
+
# the pre-scope format exactly, so existing ciphertext still decrypts.
|
|
36
|
+
info: "loam/#{purpose}/#{scope}",
|
|
37
|
+
length: KEY_BYTES,
|
|
38
|
+
hash: "SHA256"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "loam/encryption/key_provider"
|
|
3
|
+
require "loam/encryption/cipher"
|
|
4
|
+
|
|
5
|
+
module Loam
|
|
6
|
+
# Field-level encryption at rest, keyed per tenant.
|
|
7
|
+
#
|
|
8
|
+
# The facade the rest of Loam calls: `encrypt`/`decrypt` seal and open a value
|
|
9
|
+
# with the tenant's derived AES-256-GCM key, and `blind_index` computes the
|
|
10
|
+
# per-tenant HMAC used to find an encrypted field by exact value. Key
|
|
11
|
+
# derivation is delegated to a pluggable `key_provider` (HKDF by default, a
|
|
12
|
+
# KMS in production), so this module holds the scheme, not the key material.
|
|
13
|
+
module Encryption
|
|
14
|
+
class Error < Loam::Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when a crypto operation is attempted with no master key configured.
|
|
17
|
+
class MissingMasterKeyError < Error
|
|
18
|
+
def initialize(msg = "Loam::Encryption has no master key. Set LOAM_MASTER_KEY (or " \
|
|
19
|
+
"`Loam::Encryption.master_key = ...`) to a high-entropy secret, e.g. " \
|
|
20
|
+
"`SecureRandom.hex(32)`. NEVER commit it; use ENV or Rails credentials.")
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raised by decrypt on the wrong key, tampering, truncation, or garbage —
|
|
26
|
+
# one loud, undifferentiated failure.
|
|
27
|
+
class DecryptionError < Error; end
|
|
28
|
+
|
|
29
|
+
# HKDF extracts entropy from whatever it is given, but a short master key is
|
|
30
|
+
# a short master key — refuse anything below 256 bits of material.
|
|
31
|
+
MASTER_KEY_MIN_BYTES = 32
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
attr_writer :key_provider
|
|
35
|
+
|
|
36
|
+
def key_provider
|
|
37
|
+
@key_provider ||= HkdfKeyProvider.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def master_key=(value)
|
|
41
|
+
@master_key = value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def master_key
|
|
45
|
+
key = @master_key || ENV["LOAM_MASTER_KEY"]
|
|
46
|
+
raise MissingMasterKeyError if key.nil? || key.empty?
|
|
47
|
+
if key.bytesize < MASTER_KEY_MIN_BYTES
|
|
48
|
+
raise MissingMasterKeyError,
|
|
49
|
+
"LOAM_MASTER_KEY is too short (#{key.bytesize} bytes); use at least " \
|
|
50
|
+
"#{MASTER_KEY_MIN_BYTES}, e.g. `SecureRandom.hex(32)`."
|
|
51
|
+
end
|
|
52
|
+
key
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Tenant-scoped operations — the default for entity fields via
|
|
56
|
+
# Loam::Encryptable. nil stays nil (an unset field is not "the empty
|
|
57
|
+
# string encrypted"); any other value is stringified and sealed.
|
|
58
|
+
def encrypt(plaintext, tenant_id)
|
|
59
|
+
encrypt_scoped(plaintext, tenant_scope(tenant_id))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def decrypt(payload, tenant_id)
|
|
63
|
+
decrypt_scoped(payload, tenant_scope(tenant_id))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A deterministic, per-tenant keyed hash for exact-match lookup of an
|
|
67
|
+
# encrypted field. It leaks equality WITHIN a tenant (same value → same
|
|
68
|
+
# hash) — the accepted trade-off for searchability — but the per-tenant
|
|
69
|
+
# HMAC key means the same value hashes differently across tenants, so
|
|
70
|
+
# 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))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Explicit-scope variants, for data owned by something OTHER than a tenant
|
|
76
|
+
# — an MFA secret, say, keyed "user/42" so it decrypts in whatever tenant
|
|
77
|
+
# the user is currently in, or at login when no tenant is chosen yet.
|
|
78
|
+
def encrypt_scoped(plaintext, scope, aad: nil)
|
|
79
|
+
return nil if plaintext.nil?
|
|
80
|
+
Cipher.seal(plaintext.to_s, data_key(scope, :encryption), aad: aad)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def decrypt_scoped(payload, scope, aad: nil)
|
|
84
|
+
return nil if payload.nil?
|
|
85
|
+
Cipher.open(payload, data_key(scope, :encryption), aad: aad)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The Additional Authenticated Data that BINDS a ciphertext to where it
|
|
89
|
+
# lives — the key scope (tenant/owner) + table + column. Reconstructed
|
|
90
|
+
# identically on read and write, so a blob moved to a different column,
|
|
91
|
+
# table, or tenant fails the auth tag. NOT the record id (see
|
|
92
|
+
# Loam::Encryptable): the id is unknown at INSERT time, and binding it would
|
|
93
|
+
# force an ugly post-insert double-write; record-swap within one
|
|
94
|
+
# tenant+table+column stays a documented residual.
|
|
95
|
+
def aad(scope, table, column)
|
|
96
|
+
"loam-aad:v2:#{scope}:#{table}:#{column}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def blind_index_scoped(value, scope)
|
|
100
|
+
return nil if value.nil?
|
|
101
|
+
OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, :blind_index), value.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# "tenant/5" reproduces the pre-scope HKDF info exactly (see HkdfKeyProvider).
|
|
107
|
+
def tenant_scope(tenant_id)
|
|
108
|
+
"tenant/#{tenant_id}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Central guard for every crypto path (tenant and explicit scope alike): a
|
|
112
|
+
# nil tenant makes the scope "tenant/", a nil owner id makes "user/" — a
|
|
113
|
+
# degenerate scope that would otherwise derive a real, SHARED key. Refuse
|
|
114
|
+
# it here so `encrypt(x, nil)` fails like the Encryptable-layer guard does,
|
|
115
|
+
# rather than silently keying unrelated records together.
|
|
116
|
+
def data_key(scope, purpose)
|
|
117
|
+
if scope.nil? || scope.to_s.strip.empty? || scope.to_s.end_with?("/")
|
|
118
|
+
raise ArgumentError, "refusing to derive an encryption key from a degenerate scope #{scope.inspect}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
key_provider.data_key(scope: scope, purpose: purpose)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/loam/engine.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
# Not isolated on purpose: Loam models live under the Loam:: namespace but
|
|
4
|
+
# share the host app's routes/helpers, keeping the prototype surface small.
|
|
5
|
+
|
|
6
|
+
# Webhook dispatch listens to every Loam event. Wired after initialization
|
|
7
|
+
# so the models it queries are loadable, and guarded against subscribing
|
|
8
|
+
# twice (see Loam::Webhooks.subscribe!).
|
|
9
|
+
config.after_initialize do
|
|
10
|
+
Loam::Webhooks.subscribe!
|
|
11
|
+
Loam::DurableEvents.subscribe! # persist + retry durable subscribers (L-706)
|
|
12
|
+
Loam::BusinessRules.subscribe!
|
|
13
|
+
Loam::Widgets.register_builtins! # the default dashboard widgets
|
|
14
|
+
Loam::Overrides.check! # warn about any stale disable/replace overrides
|
|
15
|
+
|
|
16
|
+
# The durability sweep runs per-tenant on a schedule (materialized into
|
|
17
|
+
# each tenant by Loam::Scheduler.sync_tenant). interval:300 = every 5 min.
|
|
18
|
+
Loam::Scheduler.register(
|
|
19
|
+
key: Loam::DurableEvents::SWEEP_KEY, name: "Event redelivery sweep",
|
|
20
|
+
job_class: "Loam::EventRedeliverySweepJob", schedule: "interval:300", scope: "tenant"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Loam's Active Record layer — TenantRecord, Auditable, Eventful and the
|
|
25
|
+
# rest — is defined inside ActiveSupport.on_load(:active_record) in
|
|
26
|
+
# lib/loam.rb, so those constants exist only once something has referenced
|
|
27
|
+
# ActiveRecord::Base.
|
|
28
|
+
#
|
|
29
|
+
# Zeitwerk eager-loads app/models alphabetically. A host model that inherits
|
|
30
|
+
# from Loam::TenantRecord and sorts ahead of application_record.rb reaches
|
|
31
|
+
# the constant before anything has touched Base, and eager load dies with
|
|
32
|
+
# "uninitialized constant Loam::TenantRecord". Whether an app hits it
|
|
33
|
+
# depends on its model names, so it shows up on a rename rather than on the
|
|
34
|
+
# change that caused it.
|
|
35
|
+
#
|
|
36
|
+
# Referencing Base here fires the hook while load order is still the
|
|
37
|
+
# engine's to decide instead of each host app's. :eager_load! is late enough
|
|
38
|
+
# that every active_record.* initializer has applied its configuration, and
|
|
39
|
+
# it runs in every environment (in development it is a no-op that still
|
|
40
|
+
# executes), so lazily-loaded apps are settled by the same line.
|
|
41
|
+
initializer "loam.active_record_layer", before: :eager_load! do
|
|
42
|
+
ActiveRecord::Base
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Loam's own UI strings ship under lib/ (the gemspec packages lib/**/*), so
|
|
46
|
+
# register them on the app's I18n load path explicitly rather than relying on
|
|
47
|
+
# the default config/locales path (which the gem does not ship). An app
|
|
48
|
+
# overrides any key with its own config/locales/loam.<locale>.yml.
|
|
49
|
+
initializer "loam.i18n" do |app|
|
|
50
|
+
app.config.i18n.load_path += Dir[File.expand_path("locales/*.yml", __dir__)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# lib/tasks/loam.rake (bin/rails loam:sync) is picked up by Rails::Engine's
|
|
54
|
+
# own lib/tasks loading. Loading it again from a `rake_tasks` block here
|
|
55
|
+
# would define the task twice and run its body twice.
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Response enrichers: one module attaches a computed block onto ANOTHER
|
|
3
|
+
# module's entity in admin/API responses, with no foreign-key coupling.
|
|
4
|
+
#
|
|
5
|
+
# # billing knows about equipment; equipment knows nothing about billing:
|
|
6
|
+
# Loam::Enrichers.register("Equipment", key: "outstanding_balance") do |equipment|
|
|
7
|
+
# Invoice.where(equipment_id: equipment.id).sum(:balance)
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Loam::Enrichers.enrich(equipment) # => { "outstanding_balance" => 1200 }
|
|
11
|
+
# Loam::Enrichers.enrich_many(equipments) # => { id => { key => value }, ... }
|
|
12
|
+
#
|
|
13
|
+
# Distinct from custom fields: a custom field is STORED on the record; an
|
|
14
|
+
# enricher is COMPUTED at read time by different (possibly cross-module) code.
|
|
15
|
+
#
|
|
16
|
+
# BATCH to avoid N+1: pass `batch:` (an array -> { record.id => value }) and
|
|
17
|
+
# `enrich_many` resolves N records in one query instead of N. `enrich` reuses
|
|
18
|
+
# the batch path for a single record, so batched enrichers are cheap either way.
|
|
19
|
+
#
|
|
20
|
+
# Enrichers run in the CURRENT tenant context (the record was loaded there), so
|
|
21
|
+
# a resolver querying tenant-scoped models can only ever see this tenant's data.
|
|
22
|
+
# A resolver that raises is ISOLATED — its key is omitted, the rest still
|
|
23
|
+
# resolve, and the response is never broken. (No timeout in the prototype — a
|
|
24
|
+
# pathologically slow enricher is a future concern.)
|
|
25
|
+
module Enrichers
|
|
26
|
+
Enricher = Struct.new(:entity_type, :key, :priority, :resolver, :batch_resolver, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Register an enricher for records of `entity_type` (the model's base-class
|
|
30
|
+
# name, e.g. "Equipment"). Provide a per-record block OR a `batch:` proc.
|
|
31
|
+
def register(entity_type, key:, priority: 0, batch: nil, &resolver)
|
|
32
|
+
raise ArgumentError, "register needs a block or a batch: resolver" unless resolver || batch
|
|
33
|
+
|
|
34
|
+
registry[entity_type.to_s] << Enricher.new(
|
|
35
|
+
entity_type: entity_type.to_s, key: key.to_s, priority: priority,
|
|
36
|
+
resolver: resolver, batch_resolver: batch
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def enrich(record)
|
|
41
|
+
enrich_many([record]).fetch(record.id, {})
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# { record.id => { key => value } } for a homogeneous array of records.
|
|
45
|
+
def enrich_many(records)
|
|
46
|
+
records = Array(records)
|
|
47
|
+
return {} if records.empty?
|
|
48
|
+
|
|
49
|
+
types = records.map { |record| record.class.base_class }.uniq
|
|
50
|
+
raise ArgumentError, "enrich_many expects one entity type, got #{types.map(&:name).join(', ')}" if types.size > 1
|
|
51
|
+
|
|
52
|
+
by_id = records.each_with_object({}) { |record, hash| hash[record.id] = {} }
|
|
53
|
+
|
|
54
|
+
for_type(types.first).each do |enricher|
|
|
55
|
+
# Per-enricher isolation: a raise omits THIS key for all records; the
|
|
56
|
+
# others still resolve.
|
|
57
|
+
begin
|
|
58
|
+
if enricher.batch_resolver
|
|
59
|
+
values = enricher.batch_resolver.call(records)
|
|
60
|
+
records.each { |record| by_id[record.id][enricher.key] = values[record.id] }
|
|
61
|
+
else
|
|
62
|
+
records.each { |record| by_id[record.id][enricher.key] = enricher.resolver.call(record) }
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
warn_failure(enricher, error)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
by_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- test support: the registry is process-global, so a test snapshots it
|
|
73
|
+
# in setup and restores in teardown (keeping the app's boot-registered
|
|
74
|
+
# enrichers intact, which `clear!` would not).
|
|
75
|
+
def snapshot
|
|
76
|
+
registry.transform_values(&:dup)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def restore(snapshot)
|
|
80
|
+
@registry = Hash.new { |hash, key| hash[key] = [] }
|
|
81
|
+
snapshot.each { |type, enrichers| @registry[type] = enrichers.dup }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def registry
|
|
87
|
+
@registry ||= Hash.new { |hash, key| hash[key] = [] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The enrichers for a class. Distinct keys compose; if two register the
|
|
91
|
+
# SAME key, higher priority wins, ties go to the later registration.
|
|
92
|
+
def for_type(klass)
|
|
93
|
+
registry[klass.base_class.name]
|
|
94
|
+
.group_by(&:key)
|
|
95
|
+
.map { |_key, group| group.each_with_index.max_by { |enricher, index| [enricher.priority, index] }.first }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def warn_failure(enricher, error)
|
|
99
|
+
Rails.logger&.warn(
|
|
100
|
+
"[Loam::Enrichers] #{enricher.entity_type}/#{enricher.key} raised #{error.class}: #{error.message} — key omitted"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/loam/errors.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# Raised whenever a tenant-scoped model is touched with no tenant in
|
|
5
|
+
# Loam::Current. This is a structural guardrail, not a convention: forgetting
|
|
6
|
+
# the tenant context fails loudly (in tests, before it leaks) instead of
|
|
7
|
+
# silently returning or writing cross-tenant data.
|
|
8
|
+
class MissingTenantError < Error
|
|
9
|
+
def initialize(msg = "No tenant set in Loam::Current — wrap this call in Loam.as_tenant(tenant) { ... }")
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Raised by admin controllers / callers when a policy check fails, and by a
|
|
15
|
+
# Loam::Workflow transition the current actor's role may not perform.
|
|
16
|
+
class NotAuthorizedError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when a Loam::Workflow transition is attempted from a state it does
|
|
19
|
+
# not move from ("approve a report that was never submitted"). Like every
|
|
20
|
+
# Loam guardrail it fails at the call site rather than writing a state the
|
|
21
|
+
# machine says is impossible.
|
|
22
|
+
class InvalidTransitionError < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised when an event name does not follow the `domain.thing.happened` convention.
|
|
25
|
+
class InvalidEventNameError < Error; end
|
|
26
|
+
|
|
27
|
+
# Raised by Loam::CustomFields#custom_field/#set_custom_field when the name
|
|
28
|
+
# has no matching Loam::FieldDefinition for this tenant + entity. Fails
|
|
29
|
+
# loudly at the access site rather than silently reading/writing nil.
|
|
30
|
+
class UnknownCustomFieldError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised by require_feature! when a capability is turned OFF for the current
|
|
33
|
+
# tenant. Distinct from NotAuthorizedError on purpose: a disabled feature is
|
|
34
|
+
# "not here" (the capability does not exist for this tenant), not "you may
|
|
35
|
+
# not" — so admin controllers render it as 404, not 403.
|
|
36
|
+
class FeatureDisabledError < Error; end
|
|
37
|
+
|
|
38
|
+
# Raised when a filter/sort is attempted on a custom field the current role may
|
|
39
|
+
# not read (Loam::CustomFieldIndex) — otherwise a filter would be an inference
|
|
40
|
+
# oracle on a restricted field. A NotAuthorizedError so admin controllers render
|
|
41
|
+
# it as 403, like any other field-access denial.
|
|
42
|
+
class FieldAccessError < NotAuthorizedError; end
|
|
43
|
+
|
|
44
|
+
# Raised when a Loam::BusinessRule with a `block_transition` action vetoes a
|
|
45
|
+
# workflow transition. Distinct from NotAuthorizedError (a role gate) and
|
|
46
|
+
# InvalidTransitionError (an illegal move): the move is legal and permitted,
|
|
47
|
+
# but a rule says "not under these conditions".
|
|
48
|
+
class TransitionVetoedError < Error; end
|
|
49
|
+
end
|
data/lib/loam/eval.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Scripted evaluation scoring for the golden tasks (L-303). An agent (or a
|
|
3
|
+
# human) implements a task against a fresh Loam app; this scores the RESULT
|
|
4
|
+
# consistently — the test outcome plus the structural invariants — into a
|
|
5
|
+
# machine-readable scorecard, so runs are comparable over time
|
|
6
|
+
# (ai/benchmark_runs/). Driving the agent is out of scope; consistent scoring
|
|
7
|
+
# is the point, and it encodes the golden-tasks bar: green suite AND no
|
|
8
|
+
# invariant violated.
|
|
9
|
+
module Eval
|
|
10
|
+
# The minitest summary line, whatever the counts: "N runs, M assertions,
|
|
11
|
+
# F failures, E errors, S skips".
|
|
12
|
+
SUMMARY = /(\d+)\s+runs?,\s+(\d+)\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def parse_summary(output)
|
|
17
|
+
match = output.to_s.match(SUMMARY)
|
|
18
|
+
return nil unless match
|
|
19
|
+
|
|
20
|
+
{ runs: match[1].to_i, assertions: match[2].to_i, failures: match[3].to_i, errors: match[4].to_i }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A task PASSES only when the suite is green AND no invariant was violated —
|
|
24
|
+
# the bar the golden-tasks doc sets. `violations` is a list of invariant
|
|
25
|
+
# breaches a reviewer (or a lint) found (tenancy leak, unauthorized write, …);
|
|
26
|
+
# `interventions` counts human corrections needed. Both feed the comparison,
|
|
27
|
+
# neither is inferred here.
|
|
28
|
+
def scorecard(task:, summary:, violations: [], interventions: 0, notes: nil)
|
|
29
|
+
counts = summary || { runs: 0, assertions: 0, failures: 0, errors: 0 }
|
|
30
|
+
green = counts[:failures].to_i.zero? && counts[:errors].to_i.zero? && counts[:runs].to_i.positive?
|
|
31
|
+
passed = green && Array(violations).empty?
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
task: task.to_s,
|
|
35
|
+
passed: passed,
|
|
36
|
+
tests_green: green,
|
|
37
|
+
runs: counts[:runs].to_i,
|
|
38
|
+
assertions: counts[:assertions].to_i,
|
|
39
|
+
failures: counts[:failures].to_i,
|
|
40
|
+
errors: counts[:errors].to_i,
|
|
41
|
+
violations: Array(violations),
|
|
42
|
+
interventions: interventions.to_i,
|
|
43
|
+
notes: notes
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|