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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fda058767d913097dee8faac85c9957d680233405c3d57004cf786a9ccc155d
4
- data.tar.gz: 87763cfa7c0fa23c24111d8dceda31d73c491b6b8ead4cd1559aaeb742c7df2e
3
+ metadata.gz: 48bb3940900ff3d8ee4f6a8dc499779b3d00ed9ff37ceeab4849d36662377a47
4
+ data.tar.gz: 65440f973fc99d793ae2439cb1a1b373cd7f79d41c82398fd8d59c6e4dcba784
5
5
  SHA512:
6
- metadata.gz: 1d40e81813088308a110726cea561b9a2fa4496658e95b7292e64af28473d2363a6b629546892989fae52abd05cd13bddc76578fdd08257fed60f8736ca37efc
7
- data.tar.gz: 97caae5c7991ccadff19905a0224959aa9c40b1c446dfc39dcae4a51bb575ce685c579d24836e92069c7b8b4788c20002d519e3e613556967c4e43e63bd90f87
6
+ metadata.gz: a091472e49805c7fffe3aa95a6ddfd0ffc02fea5c7e8d368443be195854e5bdd87b4b7b7635d4c7024842d4a62015d55e6a63064009d13cce8158fff3b7bb207
7
+ data.tar.gz: 10546c0bc5b0e6facacf2ede136cd930aa3851c833ae05aa908f990bc60ea02db3efccf1483b5577c0ba4d5cf7136b369c31956a3d1c989444824a1f87836c08
data/CHANGELOG.md CHANGED
@@ -5,6 +5,122 @@ with the caveat that 0.x releases may break the public surface; the
5
5
  [backward-compatibility contract](BACKWARD_COMPATIBILITY.md) names what is
6
6
  frozen and what is not.
7
7
 
8
+ ## 0.3.0 — 2026-09-05
9
+
10
+ ### Security
11
+
12
+ A full-repo audit found 16 issues. Fifteen are fixed here, each with a regression
13
+ test verified by reverting the fix and confirming the test fails; the sixteenth
14
+ is the demo app's committed `master.key`, which is accepted — the demo holds no
15
+ real data and must never be deployed. **Anyone running 0.2.0 or earlier should
16
+ upgrade**, and the migration below is not optional: three steps change stored
17
+ data.
18
+
19
+ Worst first:
20
+
21
+ - **Cross-tenant account takeover through SSO.** A tenant could register a domain
22
+ it did not own and be handed the matching global account, then select any
23
+ tenant that account belonged to. A provider now does nothing until an operator
24
+ confirms ownership out of band (`rake open_loam:sso:verify_domain`), and
25
+ editing `domain` revokes the confirmation.
26
+ - **The MFA lockout was bypassable** — a password success cleared the TOTP
27
+ counter, so a 6-digit code could be brute-forced without limit. Each factor now
28
+ clears only its own attempts.
29
+ - **`readable:` field rules were ignored on most read paths** — the JSON API, the
30
+ generated admin index/show/form, the custom-fields partial, the CSV export,
31
+ the history screen and the edit-conflict diff all served restricted values.
32
+ Sorting by an unreadable column is refused too: ordering leaks a ranking even
33
+ when the values are hidden. A model with no policy class no longer falls back
34
+ to the base policy, which answered "any member" to everything.
35
+ - **API tokens were stored in plaintext.** A SHA-256 digest is persisted now and
36
+ the value is shown once. Offboarding a user ends their machine access, and
37
+ managers can revoke another member's token — previously nobody could.
38
+ - **SSRF through tenant-supplied URLs** — webhook endpoints and the SSO issuer
39
+ were both fetched unvalidated. `OpenLoam::OutboundUrl` checks shape at save and
40
+ pins the resolved address at fetch, so DNS rebinding cannot swap the target
41
+ after the check. The issuer must now be `https`.
42
+ - **Inbound webhook replay** — the dedupe key came from an unsigned header, so
43
+ one captured delivery could re-publish its event without limit. `external_id`
44
+ is now the body hash, the only signed material.
45
+ - **A staged change was applied without checks.** The target must now be a
46
+ `TenantRecord`, and approval runs the approver's own policy — both the action
47
+ (`create?`/`update?`/`destroy?`) and the fields the changeset writes.
48
+ - **`security.mfa_required_roles` was advisory** — enrollment was redirected to
49
+ at login and never checked again. Now enforced on every request.
50
+ - **Master-key rotation was impossible**, despite being the documented answer to
51
+ key compromise. Set `OPEN_LOAM_PREVIOUS_MASTER_KEY` to the outgoing key and
52
+ `open_loam:encryption:rotate` works.
53
+ - **The blind-index key ignored table and column**, so one value hashed alike
54
+ across every searchable encrypted column. It now binds both, like the AAD.
55
+ - **The uploaded import CSV** sat in the clear in the queue backend and the
56
+ request log. It goes to blob storage now, and the job purges it.
57
+ - **Brakeman never scanned the gem**, only `demo/`. CI now scans both, which
58
+ surfaced four warnings, all fixed. CI's `GITHUB_TOKEN` is read-only, and the
59
+ repository has secret scanning, push protection and private vulnerability
60
+ reporting enabled.
61
+
62
+ Report vulnerabilities through [private reporting](https://github.com/DeliveristsIO/open-loam/security/advisories/new),
63
+ not a public issue.
64
+
65
+ #### Migration
66
+
67
+ - `open_loam_api_tokens`: add `token_digest`, backfill `sha256(token)` per row,
68
+ drop `token`. Tokens in use keep working. See the demo's
69
+ `hash_open_loam_api_tokens`.
70
+ - `open_loam_sso_providers`: add `domain_verified_at`, then run
71
+ `rake open_loam:sso:verify_domain[<id>]` for each provider you actually own.
72
+ Until you do, SSO will not resolve for it.
73
+ - Searchable encrypted fields: re-index by running
74
+ `rake open_loam:encryption:rotate[Model,tenant_id]` for each model that has
75
+ one. The blind-index derivation changed, so existing `<field>_hash` values no
76
+ longer match.
77
+ - `delivery_id_header` on inbound webhook sources is gone; the column can be
78
+ dropped.
79
+
80
+ ### Added
81
+
82
+ - **The event log — `OpenLoam::EventLog` + `OpenLoam::EventRecord`.** Every
83
+ published event is captured as an append-only, tenant-scoped row, so a
84
+ tenant's history is queryable and replayable rather than only observable live.
85
+ `EventLog.read(name_or_prefix, since:, limit:)` and `EventLog.replay(...)` take
86
+ the same patterns as `Events.subscribe`. `OpenLoam::DurableEvents` had made
87
+ *delivery* durable; this closes *capture*, which its contract explicitly did
88
+ not cover.
89
+
90
+ Capture is on by default and captures everything except
91
+ `OpenLoam.uncaptured_events` (default: `["open_loam.progress."]`). It runs
92
+ inline, so a failed insert propagates into the publishing operation. Retention
93
+ is `OpenLoam.event_log_retention` (90 days), swept per tenant by
94
+ `OpenLoam::EventLogPruneJob`. New apps get the table from
95
+ `rails g open_loam:install`; existing apps need the
96
+ `create_open_loam_event_records` migration.
97
+
98
+ - **An authorization-called guard in the generated base controllers** (L-202).
99
+ `verify_authorized!` fails any admin or API action that finished without
100
+ calling `authorize!`, `require_role!` or `require_permission!`. Forgetting the
101
+ check was previously silent — the screen just rendered. Screens authorized
102
+ structurally declare `skip_authorization! "<reason>"`, and the reason is
103
+ required, so every exemption is a documented claim.
104
+
105
+ It runs *after* the action, so it is a development and test guard, not a
106
+ runtime access-control layer. It raises `OpenLoam::AuthorizationNotPerformedError`,
107
+ deliberately not a `NotAuthorizedError` — a developer bug must not render as a
108
+ polite 403.
109
+
110
+ Turning it on found two generated actions that never checked `read?` while
111
+ their `show`/`deleted` siblings did: the admin and JSON `index`. Both now
112
+ authorize. Existing apps will see the guard fire on any action of their own
113
+ that never authorized.
114
+
115
+ ### Changed
116
+
117
+ - **The four "wrap a proven gem" roadmap items are resolved**, recorded in
118
+ [ADR 0007](docs/_adr/0007-proven-gem-swaps-resolved.md). Tenancy (L-201) and
119
+ audit (L-203) stay in-gem for good; the Pundit swap (L-202) is declined, but
120
+ the gap it exposed — no `verify_authorized`-equivalent guard — is tracked
121
+ separately. L-204 is the event log above. No public `OpenLoam::` contract
122
+ changed.
123
+
8
124
  ## 0.2.0 — 2026-09-05
9
125
 
10
126
  ### BREAKING
data/README.md CHANGED
@@ -136,8 +136,10 @@ whole thesis in miniature.
136
136
  **How honest the "prototype" label is** — deliberately, each pillar is a
137
137
  *minimal in-gem implementation* rather than a wrapper around
138
138
  `acts_as_tenant`/`pundit`/`paper_trail`/Rails Event Store: the smallest surface
139
- that proves the conventions and the agent flow. Swapping the proven gems back in
140
- *behind the same `OpenLoam::` conventions* is the roadmap, not a reversal. Custom
139
+ that proves the conventions and the agent flow. Those swaps have since been
140
+ evaluated one at a time and settled — the in-gem versions stay, and the one real
141
+ gap they exposed (event capture) was closed in-gem too, in
142
+ [ADR 0007](docs/_adr/0007-proven-gem-swaps-resolved.md). Custom
141
143
  fields use the portable Rails `json` column (not Postgres `jsonb`/GIN) because
142
144
  the demo runs on SQLite. See [How OpenLoam works](docs/_foundation/overview.md) for
143
145
  the pillar-by-pillar breakdown and the decisions behind them.
@@ -0,0 +1,15 @@
1
+ module OpenLoam
2
+ # Enforces the event log's retention window (OpenLoam::EventLog.prune).
3
+ # Registered per-tenant in OpenLoam::Engine, so it runs inside one tenant and
4
+ # never deletes across the boundary.
5
+ class EventLogPruneJob < ActiveJob::Base
6
+ queue_as :default
7
+
8
+ def perform(tenant_id: nil)
9
+ tenant = OpenLoam::Tenant.find_by(id: tenant_id)
10
+ return if tenant.nil?
11
+
12
+ OpenLoam.as_tenant(tenant) { OpenLoam::EventLog.prune }
13
+ end
14
+ end
15
+ end
@@ -13,6 +13,10 @@ module OpenLoam
13
13
  class WebhookDeliveryJob < ActiveJob::Base
14
14
  queue_as :default
15
15
 
16
+ # A URL that points inside the perimeter will never become deliverable by
17
+ # retrying, and retrying is itself the SSRF attempt repeated.
18
+ discard_on OpenLoam::OutboundUrl::BlockedError
19
+
16
20
  TIMEOUT_SECONDS = 5
17
21
 
18
22
  def self.body_for(event_name, payload, tenant_id)
@@ -40,9 +44,14 @@ module OpenLoam
40
44
  private
41
45
 
42
46
  def deliver(endpoint, body, event_name)
43
- uri = URI.parse(endpoint.url)
47
+ # Re-checked at delivery, not just at save: the URL was validated when the
48
+ # tenant entered it, but DNS can be repointed at an internal address any
49
+ # time afterwards. Connecting to the address we just checked closes the
50
+ # window between the check and the connection.
51
+ uri, address = OpenLoam::OutboundUrl.resolve!(endpoint.url)
44
52
 
45
53
  http = Net::HTTP.new(uri.host, uri.port)
54
+ http.ipaddr = address
46
55
  http.use_ssl = uri.scheme == "https"
47
56
  http.open_timeout = TIMEOUT_SECONDS
48
57
  http.read_timeout = TIMEOUT_SECONDS
@@ -1,16 +1,31 @@
1
+ require "openssl"
2
+
1
3
  module OpenLoam
2
4
  # A bearer token that lets a machine act as one user in one tenant. Same
3
5
  # rules as a human session: whatever the token's user may do in that tenant,
4
6
  # no more. Plumbing, so not audited and not evented.
7
+ #
8
+ # Only the SHA-256 digest is stored. The plaintext is returned once, from the
9
+ # instance that generated it, and is unrecoverable afterwards — a dump of this
10
+ # table is not a set of working credentials. A 24-byte random token needs no
11
+ # slow KDF; it is not guessable the way a password is.
5
12
  class ApiToken < OpenLoam::TenantRecord
6
13
  self.table_name = "open_loam_api_tokens"
7
14
 
8
15
  belongs_to :user
9
16
 
10
- validates :token, presence: true, uniqueness: true
17
+ validates :token_digest, presence: true, uniqueness: true
18
+
19
+ # Present only on the instance that just created it — never after a reload.
20
+ attr_reader :token
11
21
 
12
22
  before_validation on: :create do
13
- self.token ||= SecureRandom.hex(24)
23
+ @token ||= SecureRandom.hex(24)
24
+ self.token_digest = self.class.digest(@token)
25
+ end
26
+
27
+ def self.digest(raw_token)
28
+ OpenSSL::Digest::SHA256.hexdigest(raw_token.to_s)
14
29
  end
15
30
 
16
31
  # THE blessed cross-tenant lookup, and the reason it lives in the gem.
@@ -27,13 +42,21 @@ module OpenLoam
27
42
  def self.authenticate(raw_token)
28
43
  return nil if raw_token.blank?
29
44
 
30
- api_token = unscoped.find_by(token: raw_token)
45
+ api_token = unscoped.find_by(token_digest: digest(raw_token))
31
46
  return nil unless api_token
32
47
 
33
48
  OpenLoam::Current.tenant = api_token.tenant
34
49
  OpenLoam::Current.actor = api_token.user
35
- api_token.update_column(:last_used_at, Time.current)
36
50
 
51
+ # A token outlives the membership that justified it, so offboarding a user
52
+ # from a tenant would otherwise leave their machine access intact. Checked
53
+ # here rather than at revoke time: the membership is the authority.
54
+ unless OpenLoam::Membership.exists?(user_id: api_token.user_id)
55
+ OpenLoam::Current.reset
56
+ return nil
57
+ end
58
+
59
+ api_token.update_column(:last_used_at, Time.current)
37
60
  api_token
38
61
  end
39
62
  end
@@ -0,0 +1,42 @@
1
+ module OpenLoam
2
+ # One captured domain event (see OpenLoam::EventLog). Where
3
+ # OpenLoam::EventDelivery records that a durable subscriber was told, this
4
+ # records that the thing happened at all.
5
+ #
6
+ # Append-only: a persisted row is readonly, so retention must delete_all
7
+ # rather than destroy.
8
+ class EventRecord < OpenLoam::TenantRecord
9
+ self.table_name = "open_loam_event_records"
10
+
11
+ validates :name, :occurred_at, presence: true
12
+
13
+ scope :chronological, -> { order(:occurred_at, :id) }
14
+
15
+ # ESCAPE is load-bearing: sanitize_sql_like backslash-escapes `_`, and SQLite
16
+ # has no default escape character, so "damage_report." would search for a
17
+ # literal backslash and silently match nothing.
18
+ scope :matching, ->(name_or_prefix) {
19
+ pattern = name_or_prefix.to_s
20
+ if pattern.end_with?(".")
21
+ where("name LIKE ? ESCAPE ?", "#{sanitize_sql_like(pattern)}%", "\\")
22
+ else
23
+ where(name: pattern)
24
+ end
25
+ }
26
+
27
+ def readonly?
28
+ persisted?
29
+ end
30
+
31
+ # The stored payload as a Hash regardless of adapter (Postgres jsonb returns
32
+ # a Hash; a text/json column may hand back a String).
33
+ def payload_hash
34
+ value = self[:payload]
35
+ return value if value.is_a?(Hash)
36
+
37
+ JSON.parse(value.to_s.presence || "{}")
38
+ rescue JSON::ParserError
39
+ {}
40
+ end
41
+ end
42
+ end
@@ -150,16 +150,26 @@ module OpenLoam
150
150
  update!(error: error.message)
151
151
  end
152
152
 
153
+ # Never settable through a staged change: soft-delete state has its own
154
+ # audited action (and its own policy question), and the rest is plumbing.
155
+ PROTECTED_FIELDS = %w[id tenant_id deleted_at lock_version].freeze
156
+
153
157
  def execute_change!
154
158
  case action_type
155
159
  when "create"
156
- record = target_class.create!(changeset)
160
+ record = target_class.new
161
+ authorize_action!(record, :create?)
162
+ record.assign_attributes(permitted_changeset(record))
163
+ record.save!
157
164
  "created #{target_type}##{record.id}"
158
165
  when "update"
159
- target_class.find(target_id).update!(changeset)
166
+ record = target_class.find(target_id)
167
+ authorize_action!(record, :update?)
168
+ record.update!(permitted_changeset(record))
160
169
  "updated #{target_type}##{target_id}"
161
170
  when "destroy"
162
171
  target = target_class.find(target_id)
172
+ authorize_action!(target, :destroy?)
163
173
  target.respond_to?(:soft_delete!) ? target.soft_delete! : target.destroy!
164
174
  "deleted #{target_type}##{target_id}"
165
175
  else
@@ -167,8 +177,42 @@ module OpenLoam
167
177
  end
168
178
  end
169
179
 
180
+ # The field rules below are not the whole policy: a host may restrict the
181
+ # ACTION itself (destroy? to an owner, say). Staging must not route around
182
+ # that, so the approver's verdict on the action is asked first.
183
+ def authorize_action!(record, question)
184
+ return if OpenLoam::Policy.for(record).public_send(question)
185
+
186
+ raise OpenLoam::NotAuthorizedError,
187
+ "the approver may not #{question.to_s.chomp('?')} #{target_type}"
188
+ end
189
+
190
+ # target_type is a string off a stored row, so it gets the same treatment as
191
+ # any other type resolution from untrusted input. The TenantRecord check is
192
+ # the load-bearing half: a non-tenant-scoped target (User, say) has no
193
+ # default scope, which would turn approval into a cross-tenant write.
170
194
  def target_class
171
- target_type.constantize
195
+ klass = target_type.to_s.safe_constantize
196
+ unless klass.is_a?(Class) && klass < OpenLoam::TenantRecord
197
+ raise OpenLoam::Error, "#{target_type.inspect} is not a tenant-scoped model — refusing to apply a staged change"
198
+ end
199
+
200
+ klass
201
+ end
202
+
203
+ # Staging is deliberately ungated ("propose" is not "apply"), so approval is
204
+ # where authority is spent and the APPROVER's field rules are what bind —
205
+ # otherwise staging routes around every one of them. Refusals raise rather
206
+ # than drop, so the proposal fails visibly instead of applying a lesser change.
207
+ def permitted_changeset(record)
208
+ blocked = changeset.keys & PROTECTED_FIELDS
209
+ raise OpenLoam::Error, "a staged change may not set #{blocked.join(', ')}" if blocked.any?
210
+
211
+ policy = OpenLoam::Policy.for(record)
212
+ refused = changeset.keys - policy.permitted_fields(changeset.keys).map(&:to_s)
213
+ raise OpenLoam::NotAuthorizedError, "the approver may not write #{refused.join(', ')}" if refused.any?
214
+
215
+ changeset
172
216
  end
173
217
 
174
218
  def load_target
@@ -25,11 +25,42 @@ module OpenLoam
25
25
 
26
26
  normalizes :domain, with: ->(domain) { domain.to_s.strip.downcase.presence }
27
27
 
28
+ # The server fetches the issuer's discovery document and posts the code
29
+ # exchange to it, so a manager-typed issuer is an SSRF vector unless it is
30
+ # provably external. https because the client_secret travels over it.
31
+ validate :issuer_is_reachable_from_outside, if: -> { issuer.present? }
32
+
33
+ # Editing the domain drops the proof — otherwise a verified provider could be
34
+ # repointed at a domain nobody approved.
35
+ before_validation :reset_domain_verification, if: -> { persisted? && domain_changed? }
36
+
28
37
  scope :active, -> { where(active: true) }
38
+ scope :domain_verified, -> { where.not(domain_verified_at: nil) }
39
+
40
+ # Until ownership is proven, HRD skips the provider and OpenLoam::Sso refuses
41
+ # to link an existing account through it.
42
+ def domain_verified? = domain_verified_at.present?
43
+
44
+ # Operator entry point (rake open_loam:sso:verify_domain), off the admin path.
45
+ def verify_domain!(at: Time.current)
46
+ update!(domain_verified_at: at)
47
+ end
29
48
 
30
49
  # IdP group -> OpenLoam role, first match wins; falls back to jit_role.
31
50
  def group_roles
32
51
  group_role_map.is_a?(Hash) ? group_role_map : {}
33
52
  end
53
+
54
+ private
55
+
56
+ def reset_domain_verification
57
+ self.domain_verified_at = nil
58
+ end
59
+
60
+ def issuer_is_reachable_from_outside
61
+ OpenLoam::OutboundUrl.validate!(issuer, require_https: true)
62
+ rescue OpenLoam::OutboundUrl::BlockedError => error
63
+ errors.add(:issuer, error.message)
64
+ end
34
65
  end
35
66
  end
@@ -8,9 +8,14 @@ module OpenLoam
8
8
  class WebhookEndpoint < OpenLoam::TenantRecord
9
9
  self.table_name = "open_loam_webhook_endpoints"
10
10
 
11
- validates :url, presence: true, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }
11
+ validates :url, presence: true
12
12
  validates :event_pattern, presence: true
13
13
 
14
+ # The server fetches this URL, so a tenant must not be able to aim it at an
15
+ # address only the server can reach. Rejected at save for a clear error;
16
+ # OpenLoam::WebhookDeliveryJob re-checks at delivery, because DNS moves.
17
+ validate :url_is_reachable_from_outside, if: -> { url.present? }
18
+
14
19
  scope :active, -> { where(active: true) }
15
20
 
16
21
  before_validation on: :create do
@@ -22,5 +27,13 @@ module OpenLoam
22
27
  def matches?(event_name)
23
28
  OpenLoam::Events.pattern_matches?(event_pattern, event_name)
24
29
  end
30
+
31
+ private
32
+
33
+ def url_is_reachable_from_outside
34
+ OpenLoam::OutboundUrl.validate!(url)
35
+ rescue OpenLoam::OutboundUrl::BlockedError => error
36
+ errors.add(:url, error.message)
37
+ end
25
38
  end
26
39
  end
@@ -3,6 +3,10 @@
3
3
  rendering lives once in the gem instead of being inlined per entity. %>
4
4
  <% mode = local_assigns.fetch(:mode, :edit) %>
5
5
  <% record.class.custom_field_definitions.each do |definition| %>
6
+ <%# Not readable by this role: omit the field entirely. Falling through to the
7
+ read-only branch below would print the value to anyone who merely lacks
8
+ WRITE access, which is a different question. %>
9
+ <% next unless policy.custom_field_readable?(definition.name) %>
6
10
  <% param_name = "#{record.model_name.param_key}[custom_fields][#{definition.name}]" %>
7
11
  <% value = record.custom_field(definition.name) %>
8
12
  <p>
@@ -8,6 +8,7 @@ module Api
8
8
  before_action :set_record, only: %i[show update destroy]
9
9
 
10
10
  def index
11
+ authorize!(policy_for(<%= class_name %>.new), :read?)
11
12
  records = <%= class_name %>.order(created_at: :desc).to_a
12
13
  enriched = OpenLoam::Enrichers.enrich_many(records) # one batched pass, no N+1
13
14
  render json: records.map { |record| entity_json(record, enrichments: enriched[record.id]) }
@@ -15,6 +15,7 @@ module Admin
15
15
  end
16
16
 
17
17
  def index
18
+ authorize!(policy_for(<%= class_name %>.new), :read?)
18
19
  @perspective = OpenLoam::Perspectives.resolve("<%= class_name %>", user: current_actor, id: params[:perspective_id])
19
20
  @perspectives = OpenLoam::Perspectives.visible_to("<%= class_name %>", user: current_actor)
20
21
  @records, @page, @has_next = paginate(index_scope)
@@ -32,6 +33,10 @@ module Admin
32
33
  # Datatable bulk actions on the selected ids — each is policy-checked per
33
34
  # record and tenant-scoped (OpenLoam::Bulk). Zero selection is a no-op.
34
35
  def bulk
36
+ # OpenLoam::Bulk checks the policy PER record (destroy? for soft_delete,
37
+ # update? + writable? for set_field) and silently skips what the actor may
38
+ # not touch, so the authorization is real — it just lives there, not here.
39
+ authorized!
35
40
  ids = Array(params[:ids])
36
41
  case params[:bulk_action]
37
42
  when "soft_delete"
@@ -147,9 +152,14 @@ module Admin
147
152
  # against real columns (never interpolated from params — that would be SQL
148
153
  # injection), and the direction is constrained to asc/desc. With no valid
149
154
  # sort param, a perspective's own order is kept, else a stable default.
155
+ #
156
+ # It must also be a column the role may READ: ordering by a hidden field
157
+ # leaks its ordering, which is an inference oracle on a value the role
158
+ # cannot see. Same reasoning as OpenLoam::FieldAccessError on custom fields —
159
+ # ignored rather than raised, since the header is not rendered either.
150
160
  def apply_sort(scope)
151
161
  column = params[:sort].to_s
152
- if <%= class_name %>.column_names.include?(column)
162
+ if <%= class_name %>.column_names.include?(column) && policy_for(<%= class_name %>.new).readable?(column)
153
163
  dir = params[:dir].to_s.casecmp("asc").zero? ? :asc : :desc
154
164
  scope.reorder(column => dir).order(id: dir)
155
165
  elsif scope.order_values.empty?
@@ -36,13 +36,16 @@
36
36
  </ul>
37
37
  <%% end %>
38
38
 
39
+ <%%# Three states per field: writable (input), readable-only (shown, labelled
40
+ read-only), or neither (absent). There is no branch for the third case on
41
+ purpose — a field the role may not read is not rendered at all. %>
39
42
  <% attributes.each do |attribute| -%>
40
43
  <%% if policy.writable?(:<%= attribute.name %>) %>
41
44
  <p>
42
45
  <%%= f.label :<%= attribute.name %> %><br>
43
46
  <%%= f.<%= form_field_helper(attribute) %> :<%= attribute.name %> %>
44
47
  </p>
45
- <%% else %>
48
+ <%% elsif policy.readable?(:<%= attribute.name %>) %>
46
49
  <p>
47
50
  <%%= f.label :<%= attribute.name %> %>: <%%= @record.<%= attribute.name %> %>
48
51
  <em><%%= t("open_loam.form.read_only") %></em>
@@ -1,3 +1,7 @@
1
+ <%%# One policy for the whole table: readable? keys off the actor's ROLE, not the
2
+ row, so building it per record would only add a membership query per row. %>
3
+ <%% policy = OpenLoam::Policy.for_model(<%= class_name %>, current_actor) %>
4
+
1
5
  <h1><%%= <%= class_name %>.model_name.human(count: 2) %></h1>
2
6
 
3
7
  <%% if @index_partial %>
@@ -55,7 +59,7 @@
55
59
  <th><input type="checkbox" onclick="document.querySelectorAll('.bulk-select').forEach(function(c){c.checked=this.checked}.bind(this))"></th>
56
60
  <th><%%= t("open_loam.labels.id") %></th>
57
61
  <% attributes.each do |attribute| -%>
58
- <th><%%= sort_link("<%= attribute.name %>", <%= class_name %>.human_attribute_name("<%= attribute.name %>")) %></th>
62
+ <%% if policy.readable?(:<%= attribute.name %>) %><th><%%= sort_link("<%= attribute.name %>", <%= class_name %>.human_attribute_name("<%= attribute.name %>")) %></th><%% end %>
59
63
  <% end -%>
60
64
  <th></th>
61
65
  </tr>
@@ -66,7 +70,7 @@
66
70
  <td><%%= check_box_tag "ids[]", record.id, false, class: "bulk-select" %></td>
67
71
  <td><%%= record.id %></td>
68
72
  <% attributes.each do |attribute| -%>
69
- <td><%%= record.<%= attribute.name %> %></td>
73
+ <%% if policy.readable?(:<%= attribute.name %>) %><td><%%= record.<%= attribute.name %> %></td><%% end %>
70
74
  <% end -%>
71
75
  <td>
72
76
  <%%= link_to t("open_loam.actions.show"), [:admin, record] %>
@@ -3,7 +3,9 @@
3
3
  <h1><%%= t("open_loam.actions.show_title", name: <%= class_name %>.model_name.human, id: @record.id) %></h1>
4
4
 
5
5
  <% attributes.each do |attribute| -%>
6
+ <%% if policy.readable?(:<%= attribute.name %>) %>
6
7
  <p><strong><%%= <%= class_name %>.human_attribute_name("<%= attribute.name %>") %>:</strong> <%%= @record.<%= attribute.name %> %></p>
8
+ <%% end %>
7
9
  <% end -%>
8
10
 
9
11
  <%%= render "open_loam/custom_fields/fields", record: @record, policy: policy, mode: :show %>
@@ -42,6 +42,7 @@ module OpenLoam
42
42
  migration_template "migrations/create_open_loam_auth_attempts.rb", "db/migrate/create_open_loam_auth_attempts.rb"
43
43
  migration_template "migrations/create_open_loam_custom_field_values.rb", "db/migrate/create_open_loam_custom_field_values.rb"
44
44
  migration_template "migrations/create_open_loam_event_deliveries.rb", "db/migrate/create_open_loam_event_deliveries.rb"
45
+ migration_template "migrations/create_open_loam_event_records.rb", "db/migrate/create_open_loam_event_records.rb"
45
46
  migration_template "migrations/create_open_loam_inbound_webhooks.rb", "db/migrate/create_open_loam_inbound_webhooks.rb"
46
47
  end
47
48
 
@@ -69,6 +70,16 @@ module OpenLoam
69
70
  template "initializer.rb", "config/initializers/open_loam.rb"
70
71
  end
71
72
 
73
+ # Rails' default filter list covers passwords and tokens but not the params
74
+ # OpenLoam introduces: an uploaded :csv is a payload of real records, and
75
+ # :code is a live TOTP.
76
+ def filter_open_loam_parameters
77
+ append_to_file "config/initializers/filter_parameter_logging.rb", <<~RUBY
78
+
79
+ Rails.application.config.filter_parameters += %i[csv file code recovery secret otp]
80
+ RUBY
81
+ end
82
+
72
83
  def create_agents_md
73
84
  template "AGENTS.md", "AGENTS.md"
74
85
  end
@@ -307,7 +307,11 @@ So the block MUST be idempotent — `find_or_create_by!`, never `create!`.
307
307
  `OpenLoam::FieldDefinition` doesn't exist — create it, don't swallow the error.
308
308
  - **Never write raw SQL that touches tenant tables** without a `tenant_id` predicate.
309
309
  - **Every controller action checks a policy** (`authorize!`); every form uses
310
- `policy.permitted_fields` — no hand-rolled `params.permit` lists.
310
+ `policy.permitted_fields` — no hand-rolled `params.permit` lists. This is
311
+ ENFORCED: an action that authorizes nothing raises
312
+ `OpenLoam::AuthorizationNotPerformedError` after it runs. If a screen really is
313
+ authorized structurally, say so with `skip_authorization! "<reason>"` — never
314
+ add a token `authorize!` to quiet the guard.
311
315
  - **Event names are `domain.thing.happened`** — three+ dot-separated segments.
312
316
  - **`OpenLoam.on_tenant_created` callbacks are idempotent** — `open_loam:sync` re-runs them.
313
317
  - **Never assign a workflow column directly** — call the transition, so the legal
@@ -1,23 +1,37 @@
1
1
  module Admin
2
- # Your own API tokens, and only ever your own: every query is scoped to
3
- # current_actor, so there is no path to another user's credentials. A token
4
- # acts as its user in this tenant, which is precisely why nobody else may
5
- # list, create or revoke one for you.
2
+ # Your own API tokens creating and listing is scoped to current_actor, so
3
+ # there is no path to another user's credentials. Revoking is the exception: a
4
+ # manager owns offboarding, and a token that outlives someone's access is the
5
+ # thing offboarding is supposed to end.
6
6
  class ApiTokensController < BaseController
7
+ skip_authorization! "Scoped to current_actor; the manager-wide revoke path calls require_role! itself."
8
+
9
+ # Revoking an API token cuts off a machine's access — sensitive enough to
10
+ # re-confirm, even for a manager (step-up is orthogonal to role).
11
+ before_action :require_sudo!, only: :destroy
12
+
7
13
  def index
8
14
  @records = api_tokens.order(created_at: :desc)
15
+ # Only the label, owner and usage — never a token value, which no longer
16
+ # exists in a readable form anyway.
17
+ @tenant_records = manager? ? OpenLoam::ApiToken.where.not(user_id: current_actor.id).order(created_at: :desc) : []
9
18
  end
10
19
 
11
20
  def create
12
21
  token = api_tokens.create!(label: params[:label].presence || "API token")
13
22
 
14
- # Shown once, on the next screen the habit that matters when tokens
15
- # are eventually stored hashed rather than in the clear.
23
+ # The only time the plaintext exists: only its digest is stored, so this
24
+ # screen is genuinely the last chance to copy it.
16
25
  redirect_to admin_api_tokens_path, flash: { token: token.token }
17
26
  end
18
27
 
19
28
  def destroy
20
- api_tokens.find(params[:id]).destroy!
29
+ # Tenant-scoped by the default scope, so another tenant's token is not
30
+ # merely forbidden here — it is not findable.
31
+ record = OpenLoam::ApiToken.find(params[:id])
32
+ require_role!(:manager) unless record.user_id == current_actor.id
33
+
34
+ record.destroy!
21
35
  redirect_to admin_api_tokens_path
22
36
  end
23
37
 
@@ -26,5 +40,9 @@ module Admin
26
40
  def api_tokens
27
41
  OpenLoam::ApiToken.where(user_id: current_actor.id)
28
42
  end
43
+
44
+ def manager?
45
+ current_role == :manager
46
+ end
29
47
  end
30
48
  end