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,89 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Advisory record locks (Loam::RecordLock) — a "who's editing this" courtesy
|
|
3
|
+
# for the multi-user admin.
|
|
4
|
+
#
|
|
5
|
+
# Loam::RecordLocks.acquire(record, by: current_actor) # take/refresh, or nil if held
|
|
6
|
+
# Loam::RecordLocks.holder(record) # the user holding it, or nil
|
|
7
|
+
# Loam::RecordLocks.release(record, by: current_actor) # give it up
|
|
8
|
+
# Loam::RecordLocks.force_release(record) # manager override
|
|
9
|
+
#
|
|
10
|
+
# THIS IS ADVISORY: a held lock warns, it does not hard-block — optimistic
|
|
11
|
+
# locking (lock_version) is the actual guarantee that two edits can't clobber.
|
|
12
|
+
# Every query is tenant-scoped by Loam::RecordLock, so a lock is invisible and
|
|
13
|
+
# unaffectable from another tenant.
|
|
14
|
+
module RecordLocks
|
|
15
|
+
DEFAULT_TTL = 5.minutes
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Take or refresh the lock if it is free (or already yours). The same
|
|
19
|
+
# holder re-acquiring extends the TTL (heartbeat). Returns the lock, or nil
|
|
20
|
+
# when someone else holds a live one. A record that no longer exists
|
|
21
|
+
# (hard- or soft-deleted) cannot be locked.
|
|
22
|
+
def acquire(record, by:, ttl: DEFAULT_TTL)
|
|
23
|
+
return nil unless present?(record)
|
|
24
|
+
|
|
25
|
+
lock = lock_row(record)
|
|
26
|
+
return nil if lock && !lock.expired? && lock.locked_by_id != by.id
|
|
27
|
+
|
|
28
|
+
lock ||= Loam::RecordLock.new(lockable_type: type_of(record), lockable_id: record.id)
|
|
29
|
+
# A fresh session token when the lock is newly created or taken over from
|
|
30
|
+
# an expired holder; kept as-is on a heartbeat by the same user.
|
|
31
|
+
lock.token = SecureRandom.hex(16) if lock.new_record? || lock.locked_by_id != by.id
|
|
32
|
+
lock.locked_by_id = by.id
|
|
33
|
+
lock.expires_at = Time.current + ttl
|
|
34
|
+
lock.save!
|
|
35
|
+
lock
|
|
36
|
+
rescue ActiveRecord::RecordNotUnique
|
|
37
|
+
# Lost the create race — someone else got it first, which is exactly the
|
|
38
|
+
# nil contract. Advisory honesty over cleverness.
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The live lock on a record, or nil — cleaning up a lock that is expired or
|
|
43
|
+
# whose record is gone (an auto-free), so orphaned rows don't linger.
|
|
44
|
+
def active_lock(record)
|
|
45
|
+
lock = lock_row(record)
|
|
46
|
+
return nil unless lock
|
|
47
|
+
|
|
48
|
+
if lock.expired? || !present?(record)
|
|
49
|
+
lock.destroy
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
lock
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def holder(record)
|
|
56
|
+
active_lock(record)&.locked_by
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def release(record, by:)
|
|
60
|
+
lock = lock_row(record)
|
|
61
|
+
lock.destroy if lock && lock.locked_by_id == by.id
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Manager override: drop whoever holds it. The controller gates the role.
|
|
66
|
+
def force_release(record)
|
|
67
|
+
lock_row(record)&.destroy
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def lock_row(record)
|
|
74
|
+
Loam::RecordLock.find_by(lockable_type: type_of(record), lockable_id: record.id)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def type_of(record)
|
|
78
|
+
record.class.base_class.name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Does the record still exist in the ordinary (non-soft-deleted) scope? A
|
|
82
|
+
# soft-deleted or hard-deleted record is "gone", so its lock auto-frees.
|
|
83
|
+
# Runs under the tenant default scope, which every call site has.
|
|
84
|
+
def present?(record)
|
|
85
|
+
record.class.base_class.where(id: record.id).exists?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Per-tenant recurring jobs. Modules self-register default schedules; an app
|
|
3
|
+
# also creates them from the admin. A runner calls Loam::Scheduler.tick
|
|
4
|
+
# periodically (wire `loam:scheduler:tick` to system cron — every minute) and
|
|
5
|
+
# each DUE schedule enqueues its ActiveJob.
|
|
6
|
+
#
|
|
7
|
+
# NO DOUBLE-FIRE — the whole correctness story. tick ATOMICALLY CLAIMS due
|
|
8
|
+
# jobs so two worker processes never enqueue the same one:
|
|
9
|
+
# * PostgreSQL: `SELECT ... FOR UPDATE SKIP LOCKED` — one worker wins each row.
|
|
10
|
+
# * SQLite (demo/harness): a transactional claim; SQLite serializes writers,
|
|
11
|
+
# which is correct for the single-process prototype.
|
|
12
|
+
# The mechanism is behind `claim_due`, chosen per adapter. A claim also stamps
|
|
13
|
+
# `locked_until` so a crashed worker's rows free themselves after LOCK_TTL.
|
|
14
|
+
module Scheduler
|
|
15
|
+
class UnknownJobError < Loam::Error; end
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# --- declarative registry (like broadcast_events / feature_defaults) ---
|
|
19
|
+
|
|
20
|
+
def register(key:, job_class:, schedule:, scope: "tenant", name: nil)
|
|
21
|
+
registry[key.to_s] = {
|
|
22
|
+
key: key.to_s, job_class: job_class.to_s, schedule: schedule.to_s,
|
|
23
|
+
scope: scope.to_s, name: (name || key).to_s
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def registered = registry.values
|
|
28
|
+
|
|
29
|
+
def reset_registry!
|
|
30
|
+
@registry = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Materialize the registered TENANT-scope schedules as rows in `tenant`
|
|
34
|
+
# (idempotent — safe from on_tenant_created and `loam:sync`). System-scope
|
|
35
|
+
# schedules are created explicitly by the app (they are not per-tenant).
|
|
36
|
+
def sync_tenant(tenant)
|
|
37
|
+
Loam.as_tenant(tenant) do
|
|
38
|
+
registered.each do |default|
|
|
39
|
+
next unless default[:scope] == "tenant"
|
|
40
|
+
|
|
41
|
+
job = Loam::ScheduledJob.find_or_initialize_by(key: default[:key])
|
|
42
|
+
job.name = default[:name]
|
|
43
|
+
job.job_class = default[:job_class]
|
|
44
|
+
job.schedule = default[:schedule]
|
|
45
|
+
job.scope = "tenant"
|
|
46
|
+
job.active = true if job.new_record?
|
|
47
|
+
job.next_run_at ||= job.compute_next_run
|
|
48
|
+
job.save!
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# --- the tick ---
|
|
54
|
+
|
|
55
|
+
# Claim and enqueue every due schedule. Returns how many fired. One job's
|
|
56
|
+
# failure never blocks the others (its lock is released so a later tick
|
|
57
|
+
# retries it).
|
|
58
|
+
def tick(now: Time.current)
|
|
59
|
+
Loam::Telemetry.span("scheduler_tick") { run_tick(now) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_tick(now)
|
|
63
|
+
fired = 0
|
|
64
|
+
claim_due(now).each do |job|
|
|
65
|
+
begin
|
|
66
|
+
enqueue_target(job)
|
|
67
|
+
job.update_columns(last_run_at: now, next_run_at: job.compute_next_run(now),
|
|
68
|
+
locked_until: nil, updated_at: now)
|
|
69
|
+
fired += 1
|
|
70
|
+
rescue StandardError => error
|
|
71
|
+
job.update_columns(locked_until: nil, updated_at: now)
|
|
72
|
+
logger&.error("[loam scheduler] #{job.key} failed: #{error.class}: #{error.message}")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
fired
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Enqueue a schedule's job right now without touching its schedule (the
|
|
79
|
+
# admin "run now" button). Re-validates job_class.
|
|
80
|
+
def run_now(job)
|
|
81
|
+
enqueue_target(job)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# THE code-execution guard: a job_class must resolve to a real ActiveJob
|
|
85
|
+
# subclass AND be ALLOWLISTED — either registered here or listed in
|
|
86
|
+
# Loam.schedulable_jobs. "Any ActiveJob" would let a tenant admin schedule
|
|
87
|
+
# ActiveStorage::PurgeJob, a mailer's delivery job, etc.
|
|
88
|
+
def resolve_job_class(name)
|
|
89
|
+
name = name.to_s
|
|
90
|
+
return nil unless allowed_job_class?(name)
|
|
91
|
+
|
|
92
|
+
klass = name.safe_constantize
|
|
93
|
+
klass if klass.is_a?(Class) && defined?(ActiveJob::Base) && klass < ActiveJob::Base
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def allowed_job_class?(name)
|
|
97
|
+
registered.any? { |default| default[:job_class] == name } || Loam.schedulable_jobs.include?(name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resolve_job_class!(name)
|
|
101
|
+
resolve_job_class(name) || raise(UnknownJobError, "#{name.inspect} is not a known ActiveJob")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def registry
|
|
107
|
+
@registry ||= {}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def enqueue_target(job)
|
|
111
|
+
klass = resolve_job_class!(job.job_class)
|
|
112
|
+
|
|
113
|
+
if job.scope == "system"
|
|
114
|
+
klass.perform_later
|
|
115
|
+
else
|
|
116
|
+
# Carry the tenant explicitly (ActiveJob doesn't serialize Loam::Current),
|
|
117
|
+
# and enqueue inside the tenant so any enqueue-time scoping is correct.
|
|
118
|
+
Loam.as_tenant(job.tenant) { klass.perform_later(tenant_id: job.tenant_id) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Atomically claim due, unlocked schedules across ALL tenants (a blessed
|
|
123
|
+
# cross-tenant scan — the runner has no tenant context, like
|
|
124
|
+
# Loam::Membership.tenants_for), stamping locked_until so a concurrent tick
|
|
125
|
+
# skips them.
|
|
126
|
+
def claim_due(now)
|
|
127
|
+
lock_until = now + Loam::ScheduledJob::LOCK_TTL
|
|
128
|
+
due = Loam::ScheduledJob.unscoped.active
|
|
129
|
+
.where("next_run_at <= ?", now)
|
|
130
|
+
.where("locked_until IS NULL OR locked_until < ?", now)
|
|
131
|
+
|
|
132
|
+
claimed = []
|
|
133
|
+
Loam::ScheduledJob.transaction do
|
|
134
|
+
relation = postgres? ? due.lock("FOR UPDATE SKIP LOCKED") : due
|
|
135
|
+
ids = relation.pluck(:id)
|
|
136
|
+
break if ids.empty?
|
|
137
|
+
|
|
138
|
+
Loam::ScheduledJob.unscoped.where(id: ids).update_all(locked_until: lock_until)
|
|
139
|
+
claimed = Loam::ScheduledJob.unscoped.where(id: ids).to_a
|
|
140
|
+
end
|
|
141
|
+
claimed
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def postgres?
|
|
145
|
+
Loam::ScheduledJob.connection.adapter_name.to_s.downcase.include?("postgres")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def logger
|
|
149
|
+
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module Search
|
|
3
|
+
# The driver contract. Subclass and override the class methods.
|
|
4
|
+
#
|
|
5
|
+
# search(model, query, scope:) -> a relation
|
|
6
|
+
# Return a subset of `scope` (already tenant-scoped, and any saved-view
|
|
7
|
+
# filter already applied) matching `query`. MUST return a relation so it
|
|
8
|
+
# composes with the caller's own `.where`/`.order`/`.limit`. A blank
|
|
9
|
+
# query returns `scope` unchanged (an empty search box is "no filter").
|
|
10
|
+
#
|
|
11
|
+
# index(record) / remove(record)
|
|
12
|
+
# Maintain the driver's index as records are saved and destroyed. Called
|
|
13
|
+
# from Loam::Searchable's after_save / after_destroy.
|
|
14
|
+
#
|
|
15
|
+
# reindex(model)
|
|
16
|
+
# Rebuild the index for a model (used by `loam:search:reindex` and seeds).
|
|
17
|
+
#
|
|
18
|
+
# A driver that needs no index (LIKE) leaves index/remove/reindex as no-ops.
|
|
19
|
+
class Driver
|
|
20
|
+
class << self
|
|
21
|
+
def search(model, query, scope:)
|
|
22
|
+
raise NotImplementedError, "#{name} must implement .search(model, query, scope:)"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def index(record) = nil
|
|
26
|
+
def remove(record) = nil
|
|
27
|
+
def reindex(model) = nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module Search
|
|
3
|
+
# The default driver: a substring LIKE across the declared columns — exactly
|
|
4
|
+
# the original Loam::Searchable behavior, now behind the seam. Portable
|
|
5
|
+
# (Arel's `matches` emits ILIKE on PostgreSQL, case-insensitive LIKE on
|
|
6
|
+
# SQLite for ASCII) and needs no index, so index/remove/reindex are no-ops.
|
|
7
|
+
#
|
|
8
|
+
# What it does NOT do — ignore accents, split on words, rank — is the reason
|
|
9
|
+
# the seam exists: swap in Loam::Search::TokenDriver (or an external engine)
|
|
10
|
+
# and every `Model.search(q)` call site keeps working unchanged.
|
|
11
|
+
class LikeDriver < Driver
|
|
12
|
+
class << self
|
|
13
|
+
def search(model, query, scope:)
|
|
14
|
+
query = query.to_s.strip
|
|
15
|
+
return scope if query.blank? || !model.loam_searchable?
|
|
16
|
+
|
|
17
|
+
# sanitize_sql_like escapes the LIKE wildcards so a literal % or _ in
|
|
18
|
+
# the query stays literal; the second arg to `matches` declares that
|
|
19
|
+
# escape character to the database (SQLite has none by default).
|
|
20
|
+
pattern = "%#{model.sanitize_sql_like(query)}%"
|
|
21
|
+
conditions = model.loam_searchable_columns.map do |column|
|
|
22
|
+
model.arel_table[column].matches(pattern, "\\")
|
|
23
|
+
end
|
|
24
|
+
scope.where(conditions.inject(:or))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module Search
|
|
3
|
+
# A portable, word-level index (the flagship second driver). Each record's
|
|
4
|
+
# searchable text is normalized into tokens stored in loam_search_tokens;
|
|
5
|
+
# a query is tokenized the same way and matched with AND semantics — a record
|
|
6
|
+
# must carry EVERY query token — so "excavator cat" finds "CAT 320 Excavator"
|
|
7
|
+
# regardless of word order or position. Plain SQL: works on SQLite and
|
|
8
|
+
# PostgreSQL, no external service, and genuinely better than LIKE (word-level,
|
|
9
|
+
# order-independent). When an app outgrows even this, the same seam takes a
|
|
10
|
+
# Meilisearch/Elasticsearch driver with no call-site change.
|
|
11
|
+
#
|
|
12
|
+
# Tenant isolation is free: Loam::SearchToken is a TenantRecord, so both the
|
|
13
|
+
# match subquery and every write are scoped to the current tenant.
|
|
14
|
+
class TokenDriver < Driver
|
|
15
|
+
class << self
|
|
16
|
+
def search(model, query, scope:)
|
|
17
|
+
tokens = tokenize(query)
|
|
18
|
+
return scope if tokens.empty? || !model.loam_searchable?
|
|
19
|
+
|
|
20
|
+
# AND semantics: keep only records carrying all N distinct query
|
|
21
|
+
# tokens. Under AND every hit has the full match count, so ranking is
|
|
22
|
+
# degenerate — ordering is left to the caller.
|
|
23
|
+
matches = Loam::SearchToken
|
|
24
|
+
.where(searchable_type: type_for(model), token: tokens)
|
|
25
|
+
.group(:searchable_id)
|
|
26
|
+
.having("COUNT(DISTINCT token) = #{tokens.size}")
|
|
27
|
+
.select(:searchable_id)
|
|
28
|
+
|
|
29
|
+
scope.where(id: matches)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rebuild one record's tokens in place. Called from after_save; runs in
|
|
33
|
+
# the record's tenant, so the token rows land in the right tenant.
|
|
34
|
+
def index(record)
|
|
35
|
+
model = record.class
|
|
36
|
+
return unless model.loam_searchable?
|
|
37
|
+
|
|
38
|
+
type = type_for(model)
|
|
39
|
+
Loam::SearchToken.where(searchable_type: type, searchable_id: record.id).delete_all
|
|
40
|
+
|
|
41
|
+
tokens = tokenize(searchable_text(record))
|
|
42
|
+
return if tokens.empty?
|
|
43
|
+
|
|
44
|
+
rows = tokens.map do |token|
|
|
45
|
+
{ tenant_id: record.tenant_id, searchable_type: type, searchable_id: record.id, token: token }
|
|
46
|
+
end
|
|
47
|
+
Loam::SearchToken.insert_all(rows)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def remove(record)
|
|
51
|
+
Loam::SearchToken.where(searchable_type: type_for(record.class), searchable_id: record.id).delete_all
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rebuild every current-tenant record of a model — run inside
|
|
55
|
+
# Loam.as_tenant per tenant (the loam:search:reindex task does that).
|
|
56
|
+
def reindex(model)
|
|
57
|
+
Loam::SearchToken.where(searchable_type: type_for(model)).delete_all
|
|
58
|
+
model.find_each { |record| index(record) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def type_for(model) = model.name
|
|
64
|
+
|
|
65
|
+
# THE SAFETY BOUNDARY: an encrypted column is skipped. Its stored value
|
|
66
|
+
# is ciphertext (meaningless to tokenize), and tokenizing the PLAINTEXT
|
|
67
|
+
# into a searchable table would recreate exactly the leak
|
|
68
|
+
# Loam::Encryptable closes — an encrypted field stays exact-match-only via
|
|
69
|
+
# its blind index. `searchable_by` already refuses to declare an
|
|
70
|
+
# encrypted column; this subtraction is the second line of that defense.
|
|
71
|
+
def searchable_text(record)
|
|
72
|
+
columns = record.class.loam_searchable_columns
|
|
73
|
+
if record.class.respond_to?(:loam_encrypted_attributes)
|
|
74
|
+
columns -= record.class.loam_encrypted_attributes
|
|
75
|
+
end
|
|
76
|
+
columns.map { |column| record.public_send(column) }.join(" ")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Downcase, split on any run of non-alphanumeric (Unicode-aware, so
|
|
80
|
+
# "Kraków" stays one token), drop blanks, de-dup. Deliberately NOT
|
|
81
|
+
# linguistic — no stemming — for the prototype.
|
|
82
|
+
def tokenize(text)
|
|
83
|
+
text.to_s.downcase.split(/[^[:alnum:]]+/).reject(&:blank?).uniq
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/loam/search.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# The search seam. `Model.search(q)` and the `searchable_by` declaration never
|
|
3
|
+
# change; the STRATEGY behind them is a swappable driver:
|
|
4
|
+
#
|
|
5
|
+
# Loam::Search.driver = Loam::Search::TokenDriver # in an initializer
|
|
6
|
+
#
|
|
7
|
+
# The default is the LIKE driver — portable and zero-setup — so nothing
|
|
8
|
+
# changes out of the box. A driver turns a text query into a subset of an
|
|
9
|
+
# already-tenant-scoped relation and maintains whatever index it needs; see
|
|
10
|
+
# Loam::Search::Driver for the contract. This is the same shape as
|
|
11
|
+
# Loam::EventStream.broadcaster and Loam::Enrichers: one seam, drivers behind
|
|
12
|
+
# it, callers untouched.
|
|
13
|
+
module Search
|
|
14
|
+
class << self
|
|
15
|
+
attr_writer :driver
|
|
16
|
+
|
|
17
|
+
def driver
|
|
18
|
+
@driver ||= Loam::Search::LikeDriver
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def search(model, query, scope:) = driver.search(model, query, scope: scope)
|
|
22
|
+
def index(record) = driver.index(record)
|
|
23
|
+
def remove(record) = driver.remove(record)
|
|
24
|
+
def reindex(model) = driver.reindex(model)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Text search over an entity's own columns:
|
|
3
|
+
#
|
|
4
|
+
# class Equipment < Loam::TenantRecord
|
|
5
|
+
# include Loam::Searchable
|
|
6
|
+
# searchable_by :name, :status
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# Equipment.search("cat") # => a relation, still tenant-scoped
|
|
10
|
+
#
|
|
11
|
+
# `search` returns a relation, so tenant isolation is not something this has
|
|
12
|
+
# to remember: the default scope is already on it, and the result composes
|
|
13
|
+
# with ordering, pagination and anything else.
|
|
14
|
+
#
|
|
15
|
+
# HOW a query matches is a swappable driver (Loam::Search.driver): the default
|
|
16
|
+
# LikeDriver is a substring LIKE; the TokenDriver is a portable word-level
|
|
17
|
+
# index; an external engine is a third. This concern is only the DECLARATION
|
|
18
|
+
# (`searchable_by`) and the untouched call site (`Model.search(q)`) — swapping
|
|
19
|
+
# the driver changes neither. See Loam::Search.
|
|
20
|
+
module Searchable
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
included do
|
|
24
|
+
class_attribute :loam_searchable_columns, default: [].freeze, instance_writer: false
|
|
25
|
+
|
|
26
|
+
# Keep the active driver's index current. A no-op under the default
|
|
27
|
+
# LikeDriver; the TokenDriver maintains loam_search_tokens here. Guarded so
|
|
28
|
+
# non-searchable models pay nothing, and skipped when no searchable column
|
|
29
|
+
# actually changed — so a soft-delete (which touches only deleted_at)
|
|
30
|
+
# leaves the tokens in place, and the base scope hides the row anyway.
|
|
31
|
+
#
|
|
32
|
+
# `respond_to?` guard: Searchable is includable in a plain class purely to
|
|
33
|
+
# exercise the `searchable_by` DSL (its encrypted-field check), with no
|
|
34
|
+
# ActiveRecord underneath — only a real model gets the callbacks.
|
|
35
|
+
if respond_to?(:after_save)
|
|
36
|
+
after_save :loam_update_search_index, if: :loam_search_index_stale?
|
|
37
|
+
after_destroy :loam_remove_from_search_index, if: -> { self.class.loam_searchable? }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class_methods do
|
|
42
|
+
# Declared columns are code, not input — no runtime validation here,
|
|
43
|
+
# because reading the schema at class-definition time would need a
|
|
44
|
+
# database connection just to boot.
|
|
45
|
+
def searchable_by(*columns)
|
|
46
|
+
columns = columns.map(&:to_s)
|
|
47
|
+
|
|
48
|
+
# The mirror of Loam::Encryptable's check: an encrypted column is
|
|
49
|
+
# ciphertext at rest, which LIKE cannot match. Caught here when
|
|
50
|
+
# `searchable_by` is the later declaration (encrypts catches the reverse).
|
|
51
|
+
if respond_to?(:loam_encrypted_attributes)
|
|
52
|
+
conflict = columns & loam_encrypted_attributes
|
|
53
|
+
if conflict.any?
|
|
54
|
+
raise Loam::Error,
|
|
55
|
+
"#{name}: cannot `searchable_by` encrypted field(s) #{conflict.join(', ')} — " \
|
|
56
|
+
"ciphertext cannot be LIKE-searched. Use `encrypts :field, searchable: true` " \
|
|
57
|
+
"for exact-match lookup instead."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
self.loam_searchable_columns = columns.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def loam_searchable?
|
|
65
|
+
loam_searchable_columns.any?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Delegates to the active driver, handing it the current relation as the
|
|
69
|
+
# base scope — so `Model.search(q)` and `some_scope.search(q)` both work
|
|
70
|
+
# (a class method called on a relation runs with `all` == that relation).
|
|
71
|
+
# A blank query returns everything: an empty search box means "no filter".
|
|
72
|
+
def search(query)
|
|
73
|
+
Loam::Search.search(self, query, scope: all)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def loam_update_search_index = Loam::Search.index(self)
|
|
80
|
+
def loam_remove_from_search_index = Loam::Search.remove(self)
|
|
81
|
+
|
|
82
|
+
# On create every column counts as changed, so a new record is always
|
|
83
|
+
# indexed; on update, reindex only when a searchable column actually moved.
|
|
84
|
+
def loam_search_index_stale?
|
|
85
|
+
return false unless self.class.loam_searchable?
|
|
86
|
+
|
|
87
|
+
saved_changes.keys.intersect?(self.class.loam_searchable_columns)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
# Soft-delete: destroying a business record hides it instead of erasing it.
|
|
3
|
+
# A recycle bin — for GDPR retention, for undo, for audits that still read
|
|
4
|
+
# cleanly after the fact. Loam is "safe by default", so a soft-deleted record
|
|
5
|
+
# is EXCLUDED from every ordinary query; seeing it is something you opt INTO,
|
|
6
|
+
# never a filter you must remember to add.
|
|
7
|
+
#
|
|
8
|
+
# equipment.soft_delete # deleted_at = now; gone from Equipment.all
|
|
9
|
+
# Equipment.with_deleted # includes it — but STILL tenant-scoped
|
|
10
|
+
# Equipment.only_deleted # just the recycle bin
|
|
11
|
+
# equipment.restore # deleted_at = nil; back in every query
|
|
12
|
+
# equipment.destroy # the real erase — the row leaves the database
|
|
13
|
+
#
|
|
14
|
+
# `destroy` still hard-deletes, for a genuine "forget me". The generated admin
|
|
15
|
+
# calls `soft_delete`, so its delete button is undoable; reach for `destroy`
|
|
16
|
+
# only when the row must actually leave the database.
|
|
17
|
+
module SoftDeletable
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
included do
|
|
21
|
+
# A second default_scope: Rails ANDs it with TenantRecord's tenant scope,
|
|
22
|
+
# so a hidden record is invisible AND still tenant-isolated.
|
|
23
|
+
default_scope { where(deleted_at: nil) }
|
|
24
|
+
|
|
25
|
+
# `unscope`, never `unscoped`: this drops ONLY the deleted_at predicate and
|
|
26
|
+
# keeps the tenant_id one, so the recycle bin can never surface another
|
|
27
|
+
# tenant's rows. `unscoped` would drop both and is forbidden here.
|
|
28
|
+
scope :with_deleted, -> { unscope(where: :deleted_at) }
|
|
29
|
+
scope :only_deleted, -> { with_deleted.where.not(deleted_at: nil) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def deleted?
|
|
33
|
+
deleted_at.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def soft_delete
|
|
37
|
+
loam_toggle_deleted(deleted: true, bang: false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def soft_delete!
|
|
41
|
+
loam_toggle_deleted(deleted: true, bang: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def restore
|
|
45
|
+
loam_toggle_deleted(deleted: false, bang: false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def restore!
|
|
49
|
+
loam_toggle_deleted(deleted: false, bang: true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# One write for all four entry points. Idempotent by design: toggling a
|
|
55
|
+
# record to the state it is already in (a double-clicked button) is a no-op,
|
|
56
|
+
# so the trail never fills with deleted_at -> deleted_at noise.
|
|
57
|
+
#
|
|
58
|
+
# A soft-delete is an UPDATE, so Loam::Auditable's after_destroy never fires
|
|
59
|
+
# for it. Rather than write our own audit row we borrow Auditable's single
|
|
60
|
+
# audit path via `loam_audit_as`, which relabels this save's audit
|
|
61
|
+
# "soft_delete"/"restore" instead of the "update" it would otherwise record.
|
|
62
|
+
def loam_toggle_deleted(deleted:, bang:)
|
|
63
|
+
return true if deleted == deleted?
|
|
64
|
+
|
|
65
|
+
self.deleted_at = deleted ? Time.current : nil
|
|
66
|
+
action = deleted ? "soft_delete" : "restore"
|
|
67
|
+
write = -> { bang ? save! : save }
|
|
68
|
+
|
|
69
|
+
respond_to?(:loam_audit_as, true) ? loam_audit_as(action, &write) : write.call
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Loam
|
|
2
|
+
module Sso
|
|
3
|
+
# Normalized identity claims, protocol-independent. Every provider's
|
|
4
|
+
# `exchange` returns one of these so the rest of the flow never knows whether
|
|
5
|
+
# it came from OIDC, SAML, or a test fake.
|
|
6
|
+
Claims = Data.define(:sub, :email, :email_verified, :name, :groups) do
|
|
7
|
+
def email_verified? = email_verified == true || email_verified.to_s == "true"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
|
|
3
|
+
module Loam
|
|
4
|
+
module Sso
|
|
5
|
+
# An OFFLINE stand-in for a real IdP — for the demo and the test suite ONLY,
|
|
6
|
+
# NEVER production. `authorization_url` loops straight back to our own
|
|
7
|
+
# callback carrying the login email (as the "code"), so the whole SSO
|
|
8
|
+
# round-trip runs with no network. `exchange` returns verified claims for
|
|
9
|
+
# that email.
|
|
10
|
+
#
|
|
11
|
+
# Tests drive the security paths through two class-level overrides (reset in
|
|
12
|
+
# teardown): `force_email_verified = false` to prove an unverified email is
|
|
13
|
+
# refused, and `claims_override` to inject arbitrary claims (a fixed `sub`,
|
|
14
|
+
# IdP groups for role mapping, a different email).
|
|
15
|
+
class FakeProvider
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :claims_override, :force_email_verified
|
|
18
|
+
|
|
19
|
+
def reset!
|
|
20
|
+
self.claims_override = nil
|
|
21
|
+
self.force_email_verified = nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(record, redirect_uri:)
|
|
26
|
+
@record = record
|
|
27
|
+
@redirect_uri = redirect_uri
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def authorization_url(state:, login_hint: nil)
|
|
31
|
+
uri = URI(@redirect_uri)
|
|
32
|
+
uri.query = { code: Base64.urlsafe_encode64(login_hint.to_s), state: state }.to_query
|
|
33
|
+
uri.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exchange(code:)
|
|
37
|
+
return self.class.claims_override if self.class.claims_override
|
|
38
|
+
|
|
39
|
+
email = Base64.urlsafe_decode64(code.to_s)
|
|
40
|
+
verified = self.class.force_email_verified.nil? ? true : self.class.force_email_verified
|
|
41
|
+
Claims.new(
|
|
42
|
+
sub: "fake|#{email}",
|
|
43
|
+
email: email,
|
|
44
|
+
email_verified: verified,
|
|
45
|
+
name: email.split("@").first,
|
|
46
|
+
groups: []
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|