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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +4 -2
- data/app/jobs/open_loam/event_log_prune_job.rb +15 -0
- data/app/jobs/open_loam/webhook_delivery_job.rb +10 -1
- data/app/models/open_loam/api_token.rb +27 -4
- data/app/models/open_loam/event_record.rb +42 -0
- data/app/models/open_loam/pending_action.rb +47 -3
- data/app/models/open_loam/sso_provider.rb +31 -0
- data/app/models/open_loam/webhook_endpoint.rb +14 -1
- data/app/views/open_loam/custom_fields/_fields.html.erb +4 -0
- data/lib/generators/open_loam/entity/templates/api_controller.rb +1 -0
- data/lib/generators/open_loam/entity/templates/controller.rb +11 -1
- data/lib/generators/open_loam/entity/templates/views/_form.html.erb +4 -1
- data/lib/generators/open_loam/entity/templates/views/index.html.erb +6 -2
- data/lib/generators/open_loam/entity/templates/views/show.html.erb +2 -0
- data/lib/generators/open_loam/install/install_generator.rb +11 -0
- data/lib/generators/open_loam/install/templates/AGENTS.md +5 -1
- data/lib/generators/open_loam/install/templates/admin/api_tokens_controller.rb +25 -7
- data/lib/generators/open_loam/install/templates/admin/api_tokens_index.html.erb +23 -0
- data/lib/generators/open_loam/install/templates/admin/base_controller.rb +63 -0
- data/lib/generators/open_loam/install/templates/admin/dashboard_controller.rb +2 -0
- data/lib/generators/open_loam/install/templates/admin/events_controller.rb +2 -0
- data/lib/generators/open_loam/install/templates/admin/history_controller.rb +26 -0
- data/lib/generators/open_loam/install/templates/admin/history_index.html.erb +1 -1
- data/lib/generators/open_loam/install/templates/admin/imports_controller.rb +13 -1
- data/lib/generators/open_loam/install/templates/admin/inbound_webhook_sources_controller.rb +1 -1
- data/lib/generators/open_loam/install/templates/admin/inbound_webhook_sources_new.html.erb +0 -2
- data/lib/generators/open_loam/install/templates/admin/mfa_controller.rb +5 -0
- data/lib/generators/open_loam/install/templates/admin/notifications_controller.rb +2 -0
- data/lib/generators/open_loam/install/templates/admin/perspectives_controller.rb +6 -0
- data/lib/generators/open_loam/install/templates/admin/progress_jobs_controller.rb +4 -0
- data/lib/generators/open_loam/install/templates/admin/search_controller.rb +2 -0
- data/lib/generators/open_loam/install/templates/admin/sessions_controller.rb +3 -4
- data/lib/generators/open_loam/install/templates/admin/sso_providers_index.html.erb +8 -1
- data/lib/generators/open_loam/install/templates/admin/sudo_controller.rb +3 -1
- data/lib/generators/open_loam/install/templates/api_base_controller.rb +43 -6
- data/lib/generators/open_loam/install/templates/import_job.rb +14 -1
- data/lib/generators/open_loam/install/templates/initializer.rb +14 -2
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_api_tokens.rb +2 -2
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_event_records.rb +13 -0
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_inbound_webhooks.rb +0 -1
- data/lib/generators/open_loam/install/templates/migrations/create_open_loam_sso_providers.rb +3 -0
- data/lib/open_loam/auth_throttle.rb +11 -3
- data/lib/open_loam/custom_field_index.rb +11 -8
- data/lib/open_loam/encryptable.rb +8 -3
- data/lib/open_loam/encryption/key_provider.rb +17 -1
- data/lib/open_loam/encryption.rb +47 -4
- data/lib/open_loam/engine.rb +8 -0
- data/lib/open_loam/errors.rb +10 -0
- data/lib/open_loam/event_log.rb +77 -0
- data/lib/open_loam/export.rb +6 -6
- data/lib/open_loam/import.rb +1 -4
- data/lib/open_loam/inbound_webhooks.rb +12 -11
- data/lib/open_loam/lifecycle.rb +29 -0
- data/lib/open_loam/mcp.rb +10 -1
- data/lib/open_loam/outbound_url.rb +100 -0
- data/lib/open_loam/policy.rb +16 -3
- data/lib/open_loam/search/token_driver.rb +1 -1
- data/lib/open_loam/sso/http_client.rb +15 -6
- data/lib/open_loam/sso.rb +23 -2
- data/lib/open_loam/version.rb +1 -1
- data/lib/open_loam.rb +2 -0
- data/lib/tasks/open_loam.rake +32 -0
- 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"
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
data/lib/open_loam/encryption.rb
CHANGED
|
@@ -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
|
-
|
|
122
|
+
# The key is bound to (scope, table, column) for the same reason the
|
|
123
|
+
# ciphertext AAD is. A key scoped only to the tenant makes one value hash
|
|
124
|
+
# identically in every searchable column in that tenant: a dump correlates
|
|
125
|
+
# rows across tables, and anyone who can write one such field gets an
|
|
126
|
+
# equality oracle against columns they cannot read.
|
|
127
|
+
#
|
|
128
|
+
# table/column default to nil so an unbound caller still works — the
|
|
129
|
+
# per-tenant key, which is what OpenLoam::PendingActions wants for an
|
|
130
|
+
# idempotency digest that is not a column at all.
|
|
131
|
+
def blind_index_scoped(value, scope, table: nil, column: nil)
|
|
100
132
|
return nil if value.nil?
|
|
101
|
-
|
|
133
|
+
|
|
134
|
+
purpose = table && column ? "blind_index/#{table}/#{column}" : :blind_index
|
|
135
|
+
OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, purpose), value.to_s)
|
|
102
136
|
end
|
|
103
137
|
|
|
104
138
|
private
|
|
@@ -120,6 +154,15 @@ module 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
|
data/lib/open_loam/engine.rb
CHANGED
|
@@ -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
|
data/lib/open_loam/errors.rb
CHANGED
|
@@ -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
|
data/lib/open_loam/export.rb
CHANGED
|
@@ -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
|
|
13
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/open_loam/import.rb
CHANGED
|
@@ -159,10 +159,7 @@ module OpenLoam
|
|
|
159
159
|
end
|
|
160
160
|
|
|
161
161
|
def policy_for(model, actor)
|
|
162
|
-
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
data/lib/open_loam/lifecycle.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/open_loam/policy.rb
CHANGED
|
@@ -19,10 +19,23 @@ module OpenLoam
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def for(record)
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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) =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
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
|