open-loam 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/README.md +4 -2
  4. data/app/jobs/open_loam/event_log_prune_job.rb +15 -0
  5. data/app/jobs/open_loam/webhook_delivery_job.rb +10 -1
  6. data/app/models/open_loam/api_token.rb +27 -4
  7. data/app/models/open_loam/event_record.rb +42 -0
  8. data/app/models/open_loam/pending_action.rb +47 -3
  9. data/app/models/open_loam/sso_provider.rb +31 -0
  10. data/app/models/open_loam/webhook_endpoint.rb +14 -1
  11. data/app/views/open_loam/custom_fields/_fields.html.erb +4 -0
  12. data/lib/generators/open_loam/entity/templates/api_controller.rb +1 -0
  13. data/lib/generators/open_loam/entity/templates/controller.rb +11 -1
  14. data/lib/generators/open_loam/entity/templates/views/_form.html.erb +4 -1
  15. data/lib/generators/open_loam/entity/templates/views/index.html.erb +6 -2
  16. data/lib/generators/open_loam/entity/templates/views/show.html.erb +2 -0
  17. data/lib/generators/open_loam/install/install_generator.rb +11 -0
  18. data/lib/generators/open_loam/install/templates/AGENTS.md +5 -1
  19. data/lib/generators/open_loam/install/templates/admin/api_tokens_controller.rb +25 -7
  20. data/lib/generators/open_loam/install/templates/admin/api_tokens_index.html.erb +23 -0
  21. data/lib/generators/open_loam/install/templates/admin/base_controller.rb +63 -0
  22. data/lib/generators/open_loam/install/templates/admin/dashboard_controller.rb +2 -0
  23. data/lib/generators/open_loam/install/templates/admin/events_controller.rb +2 -0
  24. data/lib/generators/open_loam/install/templates/admin/history_controller.rb +26 -0
  25. data/lib/generators/open_loam/install/templates/admin/history_index.html.erb +1 -1
  26. data/lib/generators/open_loam/install/templates/admin/imports_controller.rb +13 -1
  27. data/lib/generators/open_loam/install/templates/admin/inbound_webhook_sources_controller.rb +1 -1
  28. data/lib/generators/open_loam/install/templates/admin/inbound_webhook_sources_new.html.erb +0 -2
  29. data/lib/generators/open_loam/install/templates/admin/mfa_controller.rb +5 -0
  30. data/lib/generators/open_loam/install/templates/admin/notifications_controller.rb +2 -0
  31. data/lib/generators/open_loam/install/templates/admin/perspectives_controller.rb +6 -0
  32. data/lib/generators/open_loam/install/templates/admin/progress_jobs_controller.rb +4 -0
  33. data/lib/generators/open_loam/install/templates/admin/search_controller.rb +2 -0
  34. data/lib/generators/open_loam/install/templates/admin/sessions_controller.rb +3 -4
  35. data/lib/generators/open_loam/install/templates/admin/sso_providers_index.html.erb +8 -1
  36. data/lib/generators/open_loam/install/templates/admin/sudo_controller.rb +3 -1
  37. data/lib/generators/open_loam/install/templates/api_base_controller.rb +43 -6
  38. data/lib/generators/open_loam/install/templates/import_job.rb +14 -1
  39. data/lib/generators/open_loam/install/templates/initializer.rb +14 -2
  40. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_api_tokens.rb +2 -2
  41. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_event_records.rb +13 -0
  42. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_inbound_webhooks.rb +0 -1
  43. data/lib/generators/open_loam/install/templates/migrations/create_open_loam_sso_providers.rb +3 -0
  44. data/lib/open_loam/auth_throttle.rb +11 -3
  45. data/lib/open_loam/custom_field_index.rb +11 -8
  46. data/lib/open_loam/encryptable.rb +8 -3
  47. data/lib/open_loam/encryption/key_provider.rb +17 -1
  48. data/lib/open_loam/encryption.rb +47 -4
  49. data/lib/open_loam/engine.rb +8 -0
  50. data/lib/open_loam/errors.rb +10 -0
  51. data/lib/open_loam/event_log.rb +77 -0
  52. data/lib/open_loam/export.rb +6 -6
  53. data/lib/open_loam/import.rb +1 -4
  54. data/lib/open_loam/inbound_webhooks.rb +12 -11
  55. data/lib/open_loam/lifecycle.rb +29 -0
  56. data/lib/open_loam/mcp.rb +10 -1
  57. data/lib/open_loam/outbound_url.rb +100 -0
  58. data/lib/open_loam/policy.rb +16 -3
  59. data/lib/open_loam/search/token_driver.rb +1 -1
  60. data/lib/open_loam/sso/http_client.rb +15 -6
  61. data/lib/open_loam/sso.rb +23 -2
  62. data/lib/open_loam/version.rb +1 -1
  63. data/lib/open_loam.rb +2 -0
  64. data/lib/tasks/open_loam.rake +32 -0
  65. metadata +6 -1
@@ -94,7 +94,7 @@ module OpenLoam
94
94
  join = "LEFT JOIN #{table} ON #{table}.indexable_type = #{conn.quote(index_type(model))} " \
95
95
  "AND #{table}.indexable_id = #{model.table_name}.id " \
96
96
  "AND #{table}.field_key = #{conn.quote(field_key.to_s)} " \
97
- "AND #{table}.tenant_id = #{OpenLoam.tenant!.id}"
97
+ "AND #{table}.tenant_id = #{conn.quote(OpenLoam.tenant!.id)}"
98
98
  model.joins(join).order(Arel.sql("#{table}.value_text #{dir.to_s.casecmp('desc').zero? ? 'DESC' : 'ASC'}"))
99
99
  end
100
100
 
@@ -202,16 +202,19 @@ module OpenLoam
202
202
  when "neq" then rows.where.not(value_text: value.to_s)
203
203
  when "contains" then rows.where("value_text LIKE ?", "%#{sanitize_like(value)}%")
204
204
  when "present" then rows.where.not(value_text: [ nil, "" ])
205
- when "gt" then numeric(rows, value, ">")
206
- when "gte" then numeric(rows, value, ">=")
207
- when "lt" then numeric(rows, value, "<")
208
- when "lte" then numeric(rows, value, "<=")
205
+ when "gt", "gte", "lt", "lte" then numeric(rows, value, op)
209
206
  end
210
207
  end
211
208
 
212
- # Numeric comparisons hit the indexed value_number column.
213
- def numeric(rows, value, operator)
214
- rows.where("value_number #{operator} ?", value.to_f)
209
+ # Numeric comparisons hit the indexed value_number column. Built through
210
+ # Arel rather than an interpolated fragment: `op` reaches here from
211
+ # params[:cf_op], and a comparison operator cannot be a bound parameter, so
212
+ # the only safe form is one that never becomes a string.
213
+ NUMERIC_PREDICATES = { "gt" => :gt, "gte" => :gteq, "lt" => :lt, "lte" => :lteq }.freeze
214
+
215
+ def numeric(rows, value, op)
216
+ column = OpenLoam::CustomFieldValue.arel_table[:value_number]
217
+ rows.where(column.public_send(NUMERIC_PREDICATES.fetch(op), value.to_f))
215
218
  end
216
219
 
217
220
  def row_for(record, definition, type)
@@ -88,7 +88,11 @@ module OpenLoam
88
88
  write_attribute(name, OpenLoam::Encryption.encrypt_scoped(value, resolved, aad: aad))
89
89
  # The blind index tracks the ciphertext column: rewrite it in the
90
90
  # same breath, so an exact-match lookup can never go stale.
91
- write_attribute(hash_column, OpenLoam::Encryption.blind_index_scoped(value, resolved)) if searchable
91
+ if searchable
92
+ write_attribute(hash_column,
93
+ OpenLoam::Encryption.blind_index_scoped(value, resolved,
94
+ table: self.class.table_name, column: name))
95
+ end
92
96
  end
93
97
  end
94
98
  end
@@ -98,10 +102,11 @@ module OpenLoam
98
102
  # call must hash-and-compare, never match a plaintext query against the
99
103
  # ciphertext column (which would silently find nothing).
100
104
  def define_open_loam_blind_index_finder(name)
101
- hash_column = "#{name}_hash"
105
+ hash_column = :"#{name}_hash"
102
106
 
103
107
  define_singleton_method("where_#{name}") do |value|
104
- where(hash_column => OpenLoam::Encryption.blind_index(value, OpenLoam.tenant!.id))
108
+ where(hash_column => OpenLoam::Encryption.blind_index(value, OpenLoam.tenant!.id,
109
+ table: table_name, column: name))
105
110
  end
106
111
 
107
112
  define_singleton_method("find_by_#{name}") do |value|
@@ -26,10 +26,26 @@ module OpenLoam
26
26
  KEY_BYTES = 32 # AES-256 and HMAC-SHA256 both take a 32-byte key
27
27
 
28
28
  def data_key(scope:, purpose:)
29
+ derive(scope, purpose, OpenLoam::Encryption.master_key)
30
+ end
31
+
32
+ # The same derivation under the key being rotated away from, so decryption
33
+ # can fall back to it while open_loam:encryption:rotate rewrites rows under
34
+ # the new one. nil when no previous key is configured.
35
+ def previous_data_key(scope:, purpose:)
36
+ previous = OpenLoam::Encryption.previous_master_key
37
+ return nil if previous.nil?
38
+
39
+ derive(scope, purpose, previous)
40
+ end
41
+
42
+ private
43
+
44
+ def derive(scope, purpose, master)
29
45
  raise ArgumentError, "scope is required to derive a key" if scope.nil? || scope.to_s.empty?
30
46
 
31
47
  OpenSSL::KDF.hkdf(
32
- OpenLoam::Encryption.master_key,
48
+ master,
33
49
  salt: SALT,
34
50
  # info binds the key to owner AND purpose. "tenant/5" here reproduces
35
51
  # the pre-scope format exactly, so existing ciphertext still decrypts.
@@ -41,6 +41,19 @@ module OpenLoam
41
41
  @master_key = value
42
42
  end
43
43
 
44
+ attr_writer :previous_master_key
45
+
46
+ # The key being rotated AWAY from. Set it alongside the new master and
47
+ # decryption falls back to it, which is what makes rotation possible at
48
+ # all: open_loam:encryption:rotate has to READ every row under the old key
49
+ # before it can rewrite it under the new one. Unset it once the rotation
50
+ # has run everywhere.
51
+ def previous_master_key
52
+ key = defined?(@previous_master_key) ? @previous_master_key : nil
53
+ key ||= ENV["OPEN_LOAM_PREVIOUS_MASTER_KEY"]
54
+ key.presence
55
+ end
56
+
44
57
  def master_key
45
58
  key = @master_key || ENV["OPEN_LOAM_MASTER_KEY"]
46
59
  raise MissingMasterKeyError if key.nil? || key.empty?
@@ -68,8 +81,8 @@ module OpenLoam
68
81
  # hash) — the accepted trade-off for searchability — but the per-tenant
69
82
  # HMAC key means the same value hashes differently across tenants, so
70
83
  # equality never leaks between them. Only searchable fields get one.
71
- def blind_index(value, tenant_id)
72
- blind_index_scoped(value, tenant_scope(tenant_id))
84
+ def blind_index(value, tenant_id, table: nil, column: nil)
85
+ blind_index_scoped(value, tenant_scope(tenant_id), table: table, column: column)
73
86
  end
74
87
 
75
88
  # Explicit-scope variants, for data owned by something OTHER than a tenant
@@ -82,7 +95,17 @@ module OpenLoam
82
95
 
83
96
  def decrypt_scoped(payload, scope, aad: nil)
84
97
  return nil if payload.nil?
98
+
85
99
  Cipher.open(payload, data_key(scope, :encryption), aad: aad)
100
+ rescue DecryptionError
101
+ # GCM's auth tag makes "wrong key" a loud, unambiguous failure, so
102
+ # falling back is safe: a blob that opens under the previous key really
103
+ # was sealed with it. Writes always use the CURRENT key, so a row is
104
+ # rotated the moment anything saves it.
105
+ previous = previous_data_key(scope, :encryption)
106
+ raise if previous.nil?
107
+
108
+ Cipher.open(payload, previous, aad: aad)
86
109
  end
87
110
 
88
111
  # The Additional Authenticated Data that BINDS a ciphertext to where it
@@ -96,9 +119,20 @@ module OpenLoam
96
119
  "loam-aad:v2:#{scope}:#{table}:#{column}"
97
120
  end
98
121
 
99
- def blind_index_scoped(value, scope)
122
+ # The key is bound to (scope, table, column) for the same reason the
123
+ # ciphertext AAD is. A key scoped only to the tenant makes one value hash
124
+ # identically in every searchable column in that tenant: a dump correlates
125
+ # rows across tables, and anyone who can write one such field gets an
126
+ # equality oracle against columns they cannot read.
127
+ #
128
+ # table/column default to nil so an unbound caller still works — the
129
+ # per-tenant key, which is what OpenLoam::PendingActions wants for an
130
+ # idempotency digest that is not a column at all.
131
+ def blind_index_scoped(value, scope, table: nil, column: nil)
100
132
  return nil if value.nil?
101
- OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, :blind_index), value.to_s)
133
+
134
+ purpose = table && column ? "blind_index/#{table}/#{column}" : :blind_index
135
+ OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, purpose), value.to_s)
102
136
  end
103
137
 
104
138
  private
@@ -120,6 +154,15 @@ module OpenLoam
120
154
 
121
155
  key_provider.data_key(scope: scope, purpose: purpose)
122
156
  end
157
+
158
+ # nil unless a previous master is configured AND the provider supports the
159
+ # fallback — a KMS-backed provider manages its own key versions, so the
160
+ # base KeyProvider answers nil and nothing changes for it.
161
+ def previous_data_key(scope, purpose)
162
+ return nil unless key_provider.respond_to?(:previous_data_key)
163
+
164
+ key_provider.previous_data_key(scope: scope, purpose: purpose)
165
+ end
123
166
  end
124
167
  end
125
168
  end
@@ -9,6 +9,7 @@ module OpenLoam
9
9
  config.after_initialize do
10
10
  OpenLoam::Webhooks.subscribe!
11
11
  OpenLoam::DurableEvents.subscribe! # persist + retry durable subscribers (L-706)
12
+ OpenLoam::EventLog.subscribe! # capture every event as queryable history (L-204)
12
13
  OpenLoam::BusinessRules.subscribe!
13
14
  OpenLoam::Widgets.register_builtins! # the default dashboard widgets
14
15
  OpenLoam::Overrides.check! # warn about any stale disable/replace overrides
@@ -19,6 +20,13 @@ module OpenLoam
19
20
  key: OpenLoam::DurableEvents::SWEEP_KEY, name: "Event redelivery sweep",
20
21
  job_class: "OpenLoam::EventRedeliverySweepJob", schedule: "interval:300", scope: "tenant"
21
22
  )
23
+
24
+ # Event-log retention, also per-tenant. interval:86400 = daily; the window
25
+ # itself is OpenLoam.event_log_retention.
26
+ OpenLoam::Scheduler.register(
27
+ key: OpenLoam::EventLog::PRUNE_KEY, name: "Event log prune",
28
+ job_class: "OpenLoam::EventLogPruneJob", schedule: "interval:86400", scope: "tenant"
29
+ )
22
30
  end
23
31
 
24
32
  # OpenLoam's Active Record layer — TenantRecord, Auditable, Eventful and the
@@ -41,6 +41,16 @@ module OpenLoam
41
41
  # it as 403, like any other field-access denial.
42
42
  class FieldAccessError < NotAuthorizedError; end
43
43
 
44
+ # Raised when a controller action finishes without having authorized anything
45
+ # (see the `verify_authorized!` guard in the generated base controllers).
46
+ #
47
+ # Deliberately NOT a NotAuthorizedError: that one means "this person may not",
48
+ # and the base controllers render it as a tidy 403. This means "this code
49
+ # forgot to ask", which is a developer bug that must not be dressed up as a
50
+ # refusal — nothing rescues it, so it surfaces as a 500 in dev and a failure
51
+ # in tests.
52
+ class AuthorizationNotPerformedError < Error; end
53
+
44
54
  # Raised when a OpenLoam::BusinessRule with a `block_transition` action vetoes a
45
55
  # workflow transition. Distinct from NotAuthorizedError (a role gate) and
46
56
  # InvalidTransitionError (an illegal move): the move is legal and permitted,
@@ -0,0 +1,77 @@
1
+ module OpenLoam
2
+ # Captures every published event as a queryable OpenLoam::EventRecord row —
3
+ # the capture half of the event system, where OpenLoam::DurableEvents is the
4
+ # delivery half ("durability is of DELIVERY, not CAPTURE").
5
+ #
6
+ # Capture is ON by default and captures everything but OpenLoam.uncaptured_events.
7
+ # That inverts OpenLoam.broadcast_events on purpose; see ADR 0007 for why, and
8
+ # for why this stayed in-gem rather than adopting Rails Event Store.
9
+ #
10
+ # Capture runs INLINE in the publisher's thread, so a failed insert propagates
11
+ # into whatever published the event. Deliberate — do not rescue it. Swallowing
12
+ # the error re-opens the silent gap this module exists to close.
13
+ module EventLog
14
+ PRUNE_KEY = "open_loam_event_log_prune".freeze
15
+
16
+ class << self
17
+ # Idempotent: subscribing twice would capture every event twice.
18
+ def subscribe!
19
+ @subscription ||= OpenLoam::Events.subscribe_all { |event_name, payload| capture(event_name, payload) }
20
+ end
21
+
22
+ # Exclusion is the only knob, because pattern_matches? has no match-all
23
+ # spelling ("" is an exact match on the empty name). Do not add an
24
+ # allow-list: capture-all has to stay the absence of configuration.
25
+ def capturable?(event_name)
26
+ OpenLoam.uncaptured_events.none? do |pattern|
27
+ next false if OpenLoam::Overrides.disabled?(:uncaptured_events, pattern) # an app can re-enable a default exclusion
28
+
29
+ OpenLoam::Events.pattern_matches?(pattern, event_name)
30
+ end
31
+ end
32
+
33
+ def capture(event_name, payload)
34
+ return unless capturable?(event_name)
35
+
36
+ tenant = OpenLoam::Tenant.find_by(id: payload[:tenant_id])
37
+ return if tenant.nil? # nil-tenant events are not captured (as Webhooks.dispatch / DurableEvents.capture)
38
+
39
+ OpenLoam.as_tenant(tenant) do
40
+ OpenLoam::EventRecord.create!(
41
+ name: event_name.to_s,
42
+ payload: payload.transform_keys(&:to_s),
43
+ occurred_at: Time.current
44
+ )
45
+ end
46
+ end
47
+
48
+ # name_or_prefix takes an exact event name or a trailing-dot domain
49
+ # prefix, the same rule as Events.subscribe. Oldest first.
50
+ def read(name_or_prefix = nil, since: nil, limit: nil)
51
+ scope = OpenLoam::EventRecord.chronological
52
+ scope = scope.matching(name_or_prefix) if name_or_prefix.present?
53
+ scope = scope.where(occurred_at: since..) if since
54
+ scope = scope.limit(limit) if limit
55
+ scope
56
+ end
57
+
58
+ # A re-read of history, NOT a second publish: nothing else on the bus fires
59
+ # and a replayed event is not re-captured. Handlers must be idempotent, and
60
+ # get string payload keys (from the row) where a live subscriber got symbols.
61
+ def replay(name_or_prefix = nil, since: nil, limit: nil, &handler)
62
+ read(name_or_prefix, since: since, limit: limit).each do |record|
63
+ handler.call(record.name, record.payload_hash)
64
+ end
65
+ end
66
+
67
+ # delete_all, NOT destroy_all: rows are readonly once persisted, and
68
+ # destroy on a readonly record raises ActiveRecord::ReadOnlyRecord.
69
+ def prune(now: Time.current)
70
+ retention = OpenLoam.event_log_retention
71
+ return 0 if retention.nil?
72
+
73
+ OpenLoam::EventRecord.where(occurred_at: ...(now - retention)).delete_all
74
+ end
75
+ end
76
+ end
77
+ end
@@ -9,8 +9,9 @@ module OpenLoam
9
9
  # * an ENCRYPTED column is NEVER exported in the clear — its cell is
10
10
  # "[encrypted]" (the same redaction as the audit trail), so a bulk export can
11
11
  # never become a plaintext dump of PII a role shouldn't see;
12
- # * declared custom fields are included (a dictionary field exports its stored
13
- # code, so the file round-trips back through OpenLoam::Import);
12
+ # * declared custom fields go through the same read check
13
+ # (OpenLoam::Policy#custom_field_readable?) a dictionary field exports its
14
+ # stored code, so the file round-trips back through OpenLoam::Import;
14
15
  # * tenant isolation is free — the relation is already scoped.
15
16
  #
16
17
  # Prototype scale: builds the CSV in memory with the stdlib CSV. A very large
@@ -48,6 +49,8 @@ module OpenLoam
48
49
 
49
50
  if model.respond_to?(:custom_field_definitions)
50
51
  model.custom_field_definitions.order(:name).each do |definition|
52
+ next unless policy.custom_field_readable?(definition.name)
53
+
51
54
  columns << { header: definition.name, name: definition.name, kind: :custom }
52
55
  end
53
56
  end
@@ -65,10 +68,7 @@ module OpenLoam
65
68
  end
66
69
 
67
70
  def policy_for(model, actor)
68
- klass = "#{model.name}Policy".safe_constantize
69
- # A blank instance as the record: readable? keys off the role, but the
70
- # policy's custom-field checks read record.class.
71
- (klass || OpenLoam::Policy).new(actor, model.new)
71
+ OpenLoam::Policy.for_model(model, actor)
72
72
  end
73
73
  end
74
74
  end
@@ -159,10 +159,7 @@ module OpenLoam
159
159
  end
160
160
 
161
161
  def policy_for(model, actor)
162
- klass = "#{model.name}Policy".safe_constantize
163
- # A blank instance as the record: readable?/writable? key off the role, but
164
- # custom_field_writable? reads record.class.custom_field_definitions.
165
- (klass || OpenLoam::Policy).new(actor, model.new)
162
+ OpenLoam::Policy.for_model(model, actor)
166
163
  end
167
164
  end
168
165
  end
@@ -19,9 +19,12 @@ module OpenLoam
19
19
  # Every AUTH failure returns 401 with no distinguishing body, so a sender can't
20
20
  # probe which check failed; the specific reason is logged server-side only.
21
21
  #
22
- # REPLAY: the real defense is the (source_id, external_id) dedupe. The timestamp
23
- # window is defense-in-depth: unless the sender signs the timestamp too, a
24
- # replayer can refresh an unsigned timestamp header. Don't over-trust it.
22
+ # REPLAY: the real defense is the (source_id, external_id) dedupe, and
23
+ # external_id is derived from the SIGNED body never from a header. Anything
24
+ # an attacker can vary without invalidating the signature is a way to mint a
25
+ # fresh dedupe key and replay the delivery. The timestamp window is
26
+ # defense-in-depth only, for the same reason: unless the sender signs the
27
+ # timestamp, a replayer just refreshes it.
25
28
  module InboundWebhooks
26
29
  MAX_BYTES = 1_000_000
27
30
 
@@ -92,14 +95,12 @@ module OpenLoam
92
95
  (Time.current.to_i - seconds).abs <= tolerance.to_i
93
96
  end
94
97
 
95
- def delivery_id(source, headers, body)
96
- if source.delivery_id_header.present?
97
- value = header(headers, source.delivery_id_header)
98
- return value if value.present?
99
- end
100
- # No delivery-id header configured (or absent): fall back to a body hash.
101
- # Consequence: identical bodies dedupe. A sender with a real delivery id
102
- # should configure delivery_id_header so distinct-but-identical bodies pass.
98
+ # The dedupe key must come from SIGNED material, which means the body: the
99
+ # HMAC covers the body alone, so a captured (body, signature) replayed with a
100
+ # fresh delivery-id header used to mint a new external_id every time and
101
+ # re-publish the event. Cost: a sender emitting distinct deliveries with
102
+ # identical bodies sees the second deduped — its nonce belongs in the body.
103
+ def delivery_id(_source, _headers, body)
103
104
  Digest::SHA256.hexdigest(body)
104
105
  end
105
106
 
@@ -87,6 +87,27 @@ module OpenLoam
87
87
  @broadcast_events = Array(patterns).map(&:to_s)
88
88
  end
89
89
 
90
+ # Event-name patterns EXCLUDED from the event log (OpenLoam::EventLog), which
91
+ # captures everything else. An empty list captures everything; there is no
92
+ # opt-in spelling. Progress ticks fire once per row during a bulk import.
93
+ def self.uncaptured_events
94
+ @uncaptured_events ||= [ "open_loam.progress." ]
95
+ end
96
+
97
+ def self.uncaptured_events=(patterns)
98
+ @uncaptured_events = Array(patterns).map(&:to_s)
99
+ end
100
+
101
+ # How long a captured event is kept before OpenLoam::EventLogPruneJob deletes
102
+ # it. nil disables pruning, leaving an unbounded log.
103
+ def self.event_log_retention
104
+ defined?(@event_log_retention) ? @event_log_retention : 90.days
105
+ end
106
+
107
+ def self.event_log_retention=(duration)
108
+ @event_log_retention = duration
109
+ end
110
+
90
111
  # The locales content translations (OpenLoam::Translatable) may be authored in —
91
112
  # declared in the initializer (`OpenLoam.locales = %w[en de pl]`), so the admin
92
113
  # knows which languages to offer. A registry like the others; defaults to the
@@ -164,6 +185,14 @@ module OpenLoam
164
185
  def self.broadcast_events=(patterns)
165
186
  Lifecycle.broadcast_events = patterns
166
187
  end
188
+ def self.uncaptured_events = Lifecycle.uncaptured_events
189
+ def self.uncaptured_events=(patterns)
190
+ Lifecycle.uncaptured_events = patterns
191
+ end
192
+ def self.event_log_retention = Lifecycle.event_log_retention
193
+ def self.event_log_retention=(duration)
194
+ Lifecycle.event_log_retention = duration
195
+ end
167
196
  def self.locales = Lifecycle.locales
168
197
  def self.locales=(codes)
169
198
  Lifecycle.locales = codes
data/lib/open_loam/mcp.rb CHANGED
@@ -163,7 +163,10 @@ module OpenLoam
163
163
  clean = {}
164
164
  (changes || {}).each do |field, value|
165
165
  field = field.to_s
166
- raise ToolError, "#{field} cannot be set" if %w[id tenant_id lock_version].include?(field)
166
+ # deleted_at belongs with the rest: staging a direct soft-delete would
167
+ # bypass a destroy? override and the audited action label, which is
168
+ # exactly what OpenLoam::Bulk and BusinessRules both refuse.
169
+ raise ToolError, "#{field} cannot be set" if %w[id tenant_id lock_version deleted_at].include?(field)
167
170
  raise ToolError, "#{field} changes go through a workflow transition, not a direct write" if field == workflow_column
168
171
 
169
172
  if model.column_names.include?(field)
@@ -268,6 +271,12 @@ module OpenLoam
268
271
  return scope.order(id: :asc) if order.blank?
269
272
  raise ToolError, "unknown sort column #{order.inspect}" unless model.column_names.include?(order.to_s)
270
273
 
274
+ # Same gate apply_filters enforces. Values are redacted from the result,
275
+ # but ordering by a restricted column still leaks its ranking — which is
276
+ # the inference oracle the filter gate exists to close.
277
+ policy = OpenLoam::Policy.for_model(model, OpenLoam::Current.actor)
278
+ raise ToolError, "#{order} is not readable for this role" unless policy.readable?(order.to_s)
279
+
271
280
  direction = dir.to_s.casecmp("desc").zero? ? :desc : :asc
272
281
  scope.reorder(order.to_s => direction)
273
282
  end
@@ -0,0 +1,100 @@
1
+ require "ipaddr"
2
+ require "resolv"
3
+ require "uri"
4
+
5
+ module OpenLoam
6
+ # Guard for URLs a TENANT supplies and the SERVER then fetches — webhook
7
+ # endpoints and the SSO issuer. Without it the tenant names an address only
8
+ # the server can reach (169.254.169.254, loopback, a private subnet) and gets
9
+ # the result back from inside the perimeter.
10
+ #
11
+ # uri, address = OpenLoam::OutboundUrl.resolve!(endpoint.url)
12
+ # http = Net::HTTP.new(uri.host, uri.port)
13
+ # http.ipaddr = address # connect to the address that was CHECKED
14
+ #
15
+ # The pin is the load-bearing half: a host that resolves publicly during the
16
+ # check and privately a moment later (DNS rebinding) beats a name-only check.
17
+ module OutboundUrl
18
+ class BlockedError < OpenLoam::Error; end
19
+
20
+ SCHEMES = %w[http https].freeze
21
+
22
+ # Loopback, link-local (incl. the 169.254.169.254 cloud metadata service),
23
+ # RFC1918, carrier-grade NAT, and the IPv6 equivalents.
24
+ BLOCKED = [
25
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
26
+ "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15",
27
+ "224.0.0.0/4", "240.0.0.0/4",
28
+ "::1/128", "::/128", "fc00::/7", "fe80::/10", "ff00::/8"
29
+ ].map { |range| IPAddr.new(range) }.freeze
30
+
31
+ module_function
32
+
33
+ # Shape only — no DNS. Model validations call this: saving must not depend on
34
+ # the network, and a save-time lookup proves nothing resolve! doesn't re-prove.
35
+ def validate!(url, require_https: false)
36
+ uri = parse!(url, require_https: require_https)
37
+ if literal_address(uri.host) && blocked?(uri.host)
38
+ raise BlockedError, "#{uri.host} is not a public address"
39
+ end
40
+
41
+ uri
42
+ end
43
+
44
+ # Shape, DNS, and the address to connect to — call this at FETCH time.
45
+ def resolve!(url, require_https: false)
46
+ uri = validate!(url, require_https: require_https)
47
+ [ uri, address_for!(uri.host) ]
48
+ end
49
+
50
+ def parse!(url, require_https: false)
51
+ uri = begin
52
+ URI.parse(url.to_s)
53
+ rescue URI::InvalidURIError => error
54
+ raise BlockedError, "not a valid URL: #{error.message}"
55
+ end
56
+
57
+ raise BlockedError, "#{uri.scheme.inspect} is not an allowed scheme" unless SCHEMES.include?(uri.scheme)
58
+ raise BlockedError, "https is required" if require_https && uri.scheme != "https"
59
+ raise BlockedError, "no host in #{url.inspect}" if uri.host.blank?
60
+ # Credentials in the URL are a redirect/parsing-confusion trick more often
61
+ # than a real need, and nothing here has a use for them.
62
+ raise BlockedError, "credentials in the URL are not allowed" if uri.userinfo.present?
63
+
64
+ uri
65
+ end
66
+
67
+ # Every address the host resolves to must be allowed — a name with one
68
+ # public and one private A record is still an internal reach.
69
+ def address_for!(host)
70
+ addresses = resolve_all(host)
71
+ raise BlockedError, "#{host} does not resolve" if addresses.empty?
72
+
73
+ addresses.each do |address|
74
+ raise BlockedError, "#{host} resolves to the non-public address #{address}" if blocked?(address)
75
+ end
76
+ addresses.first
77
+ end
78
+
79
+ def resolve_all(host)
80
+ return [ host ] if literal_address(host)
81
+
82
+ Resolv.getaddresses(host)
83
+ rescue Resolv::ResolvError
84
+ []
85
+ end
86
+
87
+ def literal_address(host)
88
+ IPAddr.new(host.to_s)
89
+ rescue IPAddr::Error
90
+ nil
91
+ end
92
+
93
+ def blocked?(address)
94
+ ip = IPAddr.new(address.to_s)
95
+ BLOCKED.any? { |range| range.include?(ip) }
96
+ rescue IPAddr::Error
97
+ true # unparseable is not provably public
98
+ end
99
+ end
100
+ end
@@ -19,10 +19,23 @@ module OpenLoam
19
19
  end
20
20
 
21
21
  def for(record)
22
- policy_class = "#{record.class.name}Policy".safe_constantize
23
- raise Error, "No policy defined for #{record.class.name} (expected #{record.class.name}Policy)" unless policy_class
22
+ policy_class_for(record.class).new(OpenLoam::Current.actor, record)
23
+ end
24
+
25
+ # A policy for a MODEL rather than one record — for bulk paths (export,
26
+ # import) that decide the column set once, before any row is read. The
27
+ # blank instance is a stand-in: the field checks key off the role, but
28
+ # the custom-field ones need record.class to find the definitions.
29
+ def for_model(model, actor)
30
+ policy_class_for(model).new(actor, model.new)
31
+ end
24
32
 
25
- policy_class.new(OpenLoam::Current.actor, record)
33
+ # Falling back to the base Policy for a model with no policy class would
34
+ # fail OPEN — every check there answers "any member", so a missing policy
35
+ # would silently grant what an explicit one restricts.
36
+ def policy_class_for(model)
37
+ "#{model.name}Policy".safe_constantize ||
38
+ raise(Error, "No policy defined for #{model.name} (expected #{model.name}Policy)")
26
39
  end
27
40
  end
28
41
 
@@ -23,7 +23,7 @@ module OpenLoam
23
23
  matches = OpenLoam::SearchToken
24
24
  .where(searchable_type: type_for(model), token: tokens)
25
25
  .group(:searchable_id)
26
- .having("COUNT(DISTINCT token) = #{tokens.size}")
26
+ .having("COUNT(DISTINCT token) = ?", tokens.size)
27
27
  .select(:searchable_id)
28
28
 
29
29
  scope.where(id: matches)
@@ -10,22 +10,31 @@ module OpenLoam
10
10
  module_function
11
11
 
12
12
  def get_json(url, bearer: nil)
13
- uri = URI(url)
13
+ uri, address = checked(url)
14
14
  request = Net::HTTP::Get.new(uri)
15
15
  request["Authorization"] = "Bearer #{bearer}" if bearer
16
- parse(perform(uri, request))
16
+ parse(perform(uri, address, request))
17
17
  end
18
18
 
19
19
  def post_form(url, params)
20
- uri = URI(url)
20
+ uri, address = checked(url)
21
21
  request = Net::HTTP::Post.new(uri)
22
22
  request.set_form_data(params)
23
23
  request["Accept"] = "application/json"
24
- parse(perform(uri, request))
24
+ parse(perform(uri, address, request))
25
25
  end
26
26
 
27
- def perform(uri, request)
28
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
27
+ # The issuer is typed by a tenant manager, so these URLs are as untrusted
28
+ # as a webhook endpoint's. https is mandatory here rather than merely
29
+ # allowed: the client_secret and the code exchange travel over it.
30
+ def checked(url)
31
+ OpenLoam::OutboundUrl.resolve!(url, require_https: true)
32
+ rescue OpenLoam::OutboundUrl::BlockedError => error
33
+ raise OpenLoam::Sso::Error, "identity provider URL refused: #{error.message}"
34
+ end
35
+
36
+ def perform(uri, address, request)
37
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, ipaddr: address) do |http|
29
38
  http.request(request)
30
39
  end
31
40
  end