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
@@ -37,3 +37,26 @@ audit trail. Send it as <code>Authorization: Bearer &lt;token&gt;</code>.</p>
37
37
  <%% end %>
38
38
  </tbody>
39
39
  </table>
40
+
41
+ <%% if @tenant_records.any? %>
42
+ <h2>Everyone else's tokens</h2>
43
+ <p>Manager view. Removing someone's membership already stops their tokens
44
+ working; revoking is the clean end of it.</p>
45
+
46
+ <table>
47
+ <thead>
48
+ <tr><th>Owner</th><th>Label</th><th>Created</th><th>Last used</th><th></th></tr>
49
+ </thead>
50
+ <tbody>
51
+ <%% @tenant_records.each do |record| %>
52
+ <tr>
53
+ <td><%%= record.user.name %></td>
54
+ <td><%%= record.label %></td>
55
+ <td><%%= record.created_at.to_fs(:short) %></td>
56
+ <td><%%= record.last_used_at&.to_fs(:short) || "never" %></td>
57
+ <td><%%= button_to "Revoke", admin_api_token_path(record), method: :delete, form: { style: "display:inline" } %></td>
58
+ </tr>
59
+ <%% end %>
60
+ </tbody>
61
+ </table>
62
+ <%% end %>
@@ -5,8 +5,28 @@ module Admin
5
5
  layout "admin"
6
6
 
7
7
  before_action :set_open_loam_context
8
+ before_action :require_mfa_enrollment!
8
9
  before_action :set_locale
9
10
 
11
+ # Fails an action that never authorized anything. Forgetting `authorize!` is
12
+ # otherwise silent — the screen renders, and nothing says the policy was
13
+ # never consulted.
14
+ #
15
+ # It runs AFTER the action, so it catches the omission in development and in
16
+ # tests; it is not a runtime access-control layer. A `destroy` that forgot to
17
+ # authorize has already destroyed the record by the time this raises.
18
+ after_action :verify_authorized!
19
+
20
+ # For screens that are authorized STRUCTURALLY rather than by a policy call —
21
+ # a list scoped to current_actor, a pre-auth screen. The reason is required
22
+ # and shows up in the source, so every exemption is a documented claim rather
23
+ # than a silent opt-out.
24
+ def self.skip_authorization!(reason, **options)
25
+ raise ArgumentError, "skip_authorization! needs a reason" if reason.to_s.strip.empty?
26
+
27
+ skip_after_action :verify_authorized!, **options
28
+ end
29
+
10
30
  rescue_from OpenLoam::NotAuthorizedError do
11
31
  render plain: "403 Forbidden — your role does not permit this action.", status: :forbidden
12
32
  end
@@ -86,6 +106,22 @@ module Admin
86
106
  OpenLoam::Membership.exists?(user_id: current_actor.id)
87
107
  end
88
108
 
109
+ # security.mfa_required_roles has to hold on EVERY request, not just as a
110
+ # redirect at the end of login — otherwise typing any other admin URL walks
111
+ # straight past enrollment and the requirement means nothing.
112
+ def require_mfa_enrollment!
113
+ return if OpenLoam::MfaCredential.active_for(current_actor)
114
+ return unless mfa_required_role?
115
+
116
+ redirect_to new_admin_mfa_path,
117
+ alert: "Your role requires two-factor authentication — set it up to continue."
118
+ end
119
+
120
+ def mfa_required_role?
121
+ required = Array(OpenLoam::Configs.get("security.mfa_required_roles", default: []))
122
+ required.map(&:to_s).include?(current_role.to_s)
123
+ end
124
+
89
125
  # Drives the bell in the admin layout. One COUNT per admin page render,
90
126
  # which is fine at this scale; cache it if a screen ever gets hot.
91
127
  def unread_notification_count
@@ -106,9 +142,30 @@ module Admin
106
142
  end
107
143
 
108
144
  def authorize!(policy, action)
145
+ authorized!
109
146
  raise OpenLoam::NotAuthorizedError unless policy.public_send(action)
110
147
  end
111
148
 
149
+ # Records that this action asked a person-authorization question — marked
150
+ # whether the answer was yes or no, since a refusal is still a check.
151
+ #
152
+ # Call it directly only where the check is an inline `raise` rather than one
153
+ # of the helpers below (see Admin::PerspectivesController). Deliberately NOT
154
+ # called by require_feature! (a capability gate, not a person gate) or
155
+ # require_sudo! (step-up, orthogonal to role) — either marking the action
156
+ # would let a feature-gated screen skip authorization silently.
157
+ def authorized!
158
+ @open_loam_authorized = true
159
+ end
160
+
161
+ def verify_authorized!
162
+ return if @open_loam_authorized
163
+
164
+ raise OpenLoam::AuthorizationNotPerformedError,
165
+ "#{self.class.name}##{action_name} finished without authorizing. Call authorize!/require_role!/" \
166
+ "require_permission!, or declare `skip_authorization! \"<why>\"` if it is authorized structurally."
167
+ end
168
+
112
169
  # For admin screens with no per-record policy (e.g. field definitions,
113
170
  # which apply to a whole entity_type rather than one record).
114
171
  def current_role
@@ -116,6 +173,7 @@ module Admin
116
173
  end
117
174
 
118
175
  def require_role!(*roles)
176
+ authorized!
119
177
  raise OpenLoam::NotAuthorizedError unless roles.include?(current_role)
120
178
  end
121
179
 
@@ -127,6 +185,7 @@ module Admin
127
185
  end
128
186
 
129
187
  def require_permission!(permission)
188
+ authorized!
130
189
  raise OpenLoam::NotAuthorizedError unless OpenLoam.can?(permission)
131
190
  end
132
191
 
@@ -153,6 +212,10 @@ module Admin
153
212
  # be a false conflict every time.
154
213
  def stale_conflict!(record, fields)
155
214
  encrypted = record.class.respond_to?(:open_loam_encrypted_attributes) ? record.class.open_loam_encrypted_attributes.map(&:to_s) : []
215
+ # The diff shows the OTHER person's saved values, so it obeys the same field
216
+ # rules as any other read. Anything not writable was never submitted anyway.
217
+ policy = policy_for(record)
218
+ fields = fields.select { |field| policy.writable?(field) && policy.readable?(field) }
156
219
  attempted = fields.map(&:to_s).index_with { |field| conflict_value(record, field, encrypted) }
157
220
 
158
221
  record.reload
@@ -1,5 +1,7 @@
1
1
  module Admin
2
2
  class DashboardController < BaseController
3
+ skip_authorization! "Widgets are filtered by role in OpenLoam::Dashboard.for; there is no per-record subject."
4
+
3
5
  def index
4
6
  # Configurable, role-visible widgets (OpenLoam::Dashboard) — a raising widget
5
7
  # is isolated into an error tile, never breaking the page. Register your
@@ -7,6 +7,8 @@ module Admin
7
7
  # committed and there is no going back; that ordering is the whole safety of a
8
8
  # Live action.
9
9
  class EventsController < BaseController
10
+ skip_authorization! "OpenLoam::EventStream gates every message: declared pattern, matching tenant, and audience."
11
+
10
12
  include ActionController::Live
11
13
 
12
14
  HEARTBEAT_SECONDS = 20
@@ -6,6 +6,8 @@ module Admin
6
6
  class HistoryController < BaseController
7
7
  before_action :set_target
8
8
 
9
+ helper_method :visible_changeset
10
+
9
11
  def index
10
12
  authorize!(policy_for(@record), :read?)
11
13
  @audits = OpenLoam::AuditRecord
@@ -24,6 +26,30 @@ module Admin
24
26
 
25
27
  private
26
28
 
29
+ # History is a read path like any other: a field the role may not read must
30
+ # not come back here as an old → new pair.
31
+ def visible_changeset(audit)
32
+ policy = (@history_policy ||= policy_for(@record))
33
+
34
+ (audit.changeset || {}).each_with_object({}) do |(field, values), visible|
35
+ if field == "custom_fields"
36
+ readable = readable_custom_fields(values, policy)
37
+ visible[field] = readable if readable
38
+ elsif policy.readable?(field)
39
+ visible[field] = values
40
+ end
41
+ end
42
+ end
43
+
44
+ # Runtime fields are audited as one json blob, so the filter goes inside the
45
+ # old/new pair rather than dropping the whole entry.
46
+ def readable_custom_fields(values, policy)
47
+ return values unless values.is_a?(Array)
48
+
49
+ sides = values.map { |side| side.to_h.select { |name, _| policy.custom_field_readable?(name) } }
50
+ sides.any?(&:present?) ? sides : nil
51
+ end
52
+
27
53
  # The target record, looked up through its OWN default scope (tenancy holds;
28
54
  # a soft-deleted row is still reachable to restore). The type is whitelisted
29
55
  # to a OpenLoam::TenantRecord — never a bare constantize of a param.
@@ -18,7 +18,7 @@ never reverted here.</p>
18
18
  <td><code><%%= audit.action %></code></td>
19
19
  <td><%%= audit.actor&.name || "—" %></td>
20
20
  <td>
21
- <%% (audit.changeset || {}).each do |field, values| %>
21
+ <%% visible_changeset(audit).each do |field, values| %>
22
22
  <div><code><%%= field %></code>:
23
23
  <%%= values.is_a?(Array) ? "#{values.first.inspect} → #{values.last.inspect}" : values %></div>
24
24
  <%% end %>
@@ -31,7 +31,7 @@ module Admin
31
31
  if params[:commit] == "Dry run"
32
32
  run_dry(mapping, match_key)
33
33
  else
34
- ImportJob.perform_later(entity_type: @entity_type, csv: @csv, mapping: mapping,
34
+ ImportJob.perform_later(entity_type: @entity_type, blob_id: staged_csv_blob(@csv).id, mapping: mapping,
35
35
  match_key: match_key, tenant_id: current_tenant.id, actor_id: current_actor.id)
36
36
  redirect_to admin_progress_jobs_path, notice: "Import started — watch it progress under Tasks."
37
37
  end
@@ -71,5 +71,17 @@ module Admin
71
71
 
72
72
  file.read.force_encoding("UTF-8")
73
73
  end
74
+
75
+ # The CSV goes to blob storage and the JOB gets an id. ActiveJob arguments
76
+ # are serialized into the queue backend and echoed in the job log, so
77
+ # passing the file itself would leave every row sitting in the clear —
78
+ # including the columns headed for encrypted fields. ImportJob purges it.
79
+ def staged_csv_blob(csv)
80
+ ActiveStorage::Blob.create_and_upload!(
81
+ io: StringIO.new(csv),
82
+ filename: "#{@entity_type.underscore}-import-#{SecureRandom.hex(8)}.csv",
83
+ content_type: "text/csv"
84
+ )
85
+ end
74
86
  end
75
87
  end
@@ -53,7 +53,7 @@ module Admin
53
53
 
54
54
  def source_params
55
55
  params.require(:inbound_webhook_source).permit(
56
- :name, :event_name, :signature_header, :delivery_id_header,
56
+ :name, :event_name, :signature_header,
57
57
  :timestamp_header, :timestamp_tolerance
58
58
  )
59
59
  end
@@ -19,8 +19,6 @@
19
19
  <small>e.g. X-Hub-Signature-256 for GitHub.</small>
20
20
  </p>
21
21
  <p>
22
- <%%= f.label :delivery_id_header, "Delivery-id header (optional, for dedupe)" %><br>
23
- <%%= f.text_field :delivery_id_header %>
24
22
  <small>Blank = dedupe on a hash of the body.</small>
25
23
  </p>
26
24
  <p>
@@ -6,6 +6,11 @@ module Admin
6
6
  # old secret stays valid until the new one is proven — so neither a cross-site
7
7
  # GET nor an abandoned re-enrollment can downgrade an active credential.
8
8
  class MfaController < BaseController
9
+ skip_authorization! "Your own MFA credentials; sensitive changes go through require_sudo! instead."
10
+
11
+ # This IS the enrollment screen the requirement sends people to.
12
+ skip_before_action :require_mfa_enrollment!
13
+
9
14
  # Shown in authenticator apps as the account issuer; defaults to the app name.
10
15
  ISSUER = Rails.application.class.module_parent_name.freeze
11
16
 
@@ -5,6 +5,8 @@ module Admin
5
5
  # OpenLoam::TenantRecord) and BaseController has already established that this
6
6
  # actor is signed in to this tenant. Recipient scoping is the whole rule.
7
7
  class NotificationsController < BaseController
8
+ skip_authorization! "Scoped to current_actor — there is no path to another user's notifications."
9
+
8
10
  def index
9
11
  @records = notifications.order(created_at: :desc).limit(100)
10
12
  end
@@ -8,6 +8,11 @@ module Admin
8
8
  # ones. A private view of someone else's is invisible here by construction
9
9
  # (OpenLoam::Perspectives.visible_to never returns it).
10
10
  class PerspectivesController < BaseController
11
+ # update/destroy/set_default authorize via authorize_manage!. index and create
12
+ # act only on the actor's own private views, so there is no subject to gate.
13
+ skip_authorization! "index lists views visible to the actor; create only ever makes a private one, owned by them.",
14
+ only: %i[index create]
15
+
11
16
  before_action :set_perspective, only: %i[update destroy set_default]
12
17
  before_action :authorize_manage!, only: %i[update destroy set_default]
13
18
 
@@ -68,6 +73,7 @@ module Admin
68
73
  end
69
74
 
70
75
  def authorize_manage!
76
+ authorized!
71
77
  raise OpenLoam::NotAuthorizedError unless can_manage?(@perspective)
72
78
  end
73
79
 
@@ -4,6 +4,9 @@ module Admin
4
4
  # Your app starts jobs with OpenLoam::Progress.start in its own background jobs;
5
5
  # a running one can be cancelled cooperatively here.
6
6
  class ProgressJobsController < BaseController
7
+ skip_authorization! "A tenant-scoped list of the tenant's own jobs; cancel is the gated action.",
8
+ only: :index
9
+
7
10
  def index
8
11
  @jobs = OpenLoam::ProgressJob.recent.limit(20)
9
12
  end
@@ -11,6 +14,7 @@ module Admin
11
14
  def cancel
12
15
  job = OpenLoam::ProgressJob.find(params[:id])
13
16
  # Manager-or-owner: a member must not cancel another user's job.
17
+ authorized!
14
18
  raise OpenLoam::NotAuthorizedError unless current_role == :manager || job.actor_id == current_actor&.id
15
19
  job.cancel! if job.running?
16
20
  redirect_to admin_progress_jobs_path, notice: "Job cancelled."
@@ -6,6 +6,8 @@ module Admin
6
6
  # tenant-scoped relation, so this can only ever find records the current
7
7
  # tenant owns.
8
8
  class SearchController < BaseController
9
+ skip_authorization! "Each model's `search` returns its own tenant-scoped relation; reads only."
10
+
9
11
  PER_ENTITY = 10
10
12
 
11
13
  helper_method :search_result_label
@@ -39,7 +39,8 @@ module Admin
39
39
  return render :new, status: :unauthorized
40
40
  end
41
41
 
42
- OpenLoam::AuthThrottle.clear(email) # a successful login resets the counter
42
+ # PASSWORD only clearing every kind here would reset the TOTP counter.
43
+ OpenLoam::AuthThrottle.clear(email, kind: "password")
43
44
  reset_session # a fresh session id at login: no fixation
44
45
  session[:user_id] = user.id
45
46
 
@@ -58,8 +59,6 @@ module Admin
58
59
  @user = mfa_challenge_user or return redirect_to new_admin_session_path
59
60
  end
60
61
 
61
- # TODO (follow-up): rate-limit / lock out repeated failed codes here (needs
62
- # throttling infra) — a 6-digit TOTP is brute-forceable without it.
63
62
  def mfa_verify
64
63
  user = mfa_challenge_user or return redirect_to new_admin_session_path
65
64
 
@@ -74,7 +73,7 @@ module Admin
74
73
  credential = OpenLoam::MfaCredential.active_for(user)
75
74
 
76
75
  if credential.verify_totp(params[:code]) || credential.consume_recovery_code(params[:code])
77
- OpenLoam::AuthThrottle.clear(user.email)
76
+ OpenLoam::AuthThrottle.clear(user.email, kind: "totp")
78
77
  session.delete(:mfa_pending)
79
78
  complete_authentication(user)
80
79
  else
@@ -4,9 +4,15 @@
4
4
  below is sent to that identity provider to sign in (OIDC), and provisioned on
5
5
  first login. <%%= link_to "New provider", new_admin_sso_provider_path %></p>
6
6
 
7
+ <p>A provider does nothing until an operator confirms your organisation owns the
8
+ domain — anyone can type a domain here, so the claim has to be checked off the
9
+ admin path. Ask your operator to run
10
+ <code>rake open_loam:sso:verify_domain[&lt;id&gt;]</code>. Editing the domain
11
+ later clears the confirmation.</p>
12
+
7
13
  <table>
8
14
  <thead>
9
- <tr><th>Name</th><th>Protocol</th><th>Domain</th><th>JIT role</th><th>Active</th><th></th></tr>
15
+ <tr><th>Name</th><th>Protocol</th><th>Domain</th><th>Ownership</th><th>JIT role</th><th>Active</th><th></th></tr>
10
16
  </thead>
11
17
  <tbody>
12
18
  <%% @providers.each do |provider| %>
@@ -14,6 +20,7 @@ first login. <%%= link_to "New provider", new_admin_sso_provider_path %></p>
14
20
  <td><%%= provider.name %></td>
15
21
  <td><%%= provider.protocol %></td>
16
22
  <td><code><%%= provider.domain %></code></td>
23
+ <td><%%= provider.domain_verified? ? "confirmed" : "not confirmed — SSO is inactive" %></td>
17
24
  <td><%%= provider.jit_role %></td>
18
25
  <td><%%= provider.active? ? "yes" : "no" %></td>
19
26
  <td>
@@ -4,6 +4,8 @@ module Admin
4
4
  # (or TOTP code, if the user has MFA) stamps a fresh sudo timestamp and returns
5
5
  # to wherever they were headed.
6
6
  class SudoController < BaseController
7
+ skip_authorization! "The step-up re-challenge itself — authorizing it would require the sudo it grants."
8
+
7
9
  def new
8
10
  end
9
11
 
@@ -16,7 +18,7 @@ module Admin
16
18
  end
17
19
 
18
20
  if reauthenticated?
19
- OpenLoam::AuthThrottle.clear(identifier)
21
+ OpenLoam::AuthThrottle.clear(identifier, kind: "sudo")
20
22
  session[:sudo_at] = Time.now.to_i
21
23
  redirect_to(session.delete(:sudo_return_to).presence || admin_root_path,
22
24
  notice: "Re-authenticated — you can complete the action now.")
@@ -14,6 +14,17 @@ module Api
14
14
  class BaseController < ActionController::API
15
15
  before_action :authenticate_api_token!
16
16
 
17
+ # Same guard as the admin base controller: an endpoint that never authorized
18
+ # anything fails loudly rather than silently serving. Runs after the action,
19
+ # so it catches the omission in tests, not at runtime.
20
+ after_action :verify_authorized!
21
+
22
+ def self.skip_authorization!(reason, **options)
23
+ raise ArgumentError, "skip_authorization! needs a reason" if reason.to_s.strip.empty?
24
+
25
+ skip_after_action :verify_authorized!, **options
26
+ end
27
+
17
28
  rescue_from OpenLoam::NotAuthorizedError do
18
29
  render json: { error: "forbidden" }, status: :forbidden
19
30
  end
@@ -46,27 +57,53 @@ module Api
46
57
  end
47
58
 
48
59
  def authorize!(policy, action)
60
+ authorized!
49
61
  raise OpenLoam::NotAuthorizedError unless policy.public_send(action)
50
62
  end
51
63
 
52
- # The JSON shape of an entity: its columns, custom fields included (they
53
- # live in the `custom_fields` column that every generated entity carries).
64
+ # Marked whether the answer was yes or no a refusal is still a check.
65
+ def authorized!
66
+ @open_loam_authorized = true
67
+ end
68
+
69
+ def verify_authorized!
70
+ return if @open_loam_authorized
71
+
72
+ raise OpenLoam::AuthorizationNotPerformedError,
73
+ "#{self.class.name}##{action_name} finished without authorizing. Call authorize!, or declare " \
74
+ "`skip_authorization! \"<why>\"` if it is authorized structurally."
75
+ end
76
+
77
+ # The JSON shape of an entity: the columns this actor's role may READ, plus
78
+ # the readable custom fields (which live in the `custom_fields` column that
79
+ # every generated entity carries).
80
+ #
81
+ # The read check is the same one the CSV export and the admin screens apply.
82
+ # Without it the API is a way around every `field readable:` rule in the app:
83
+ # the record is already loaded and `record.attributes` emits everything on it.
54
84
  #
55
85
  # Encrypted fields (OpenLoam::Encryptable) are returned DECRYPTED — the caller is
56
86
  # authenticated, tenant-scoped and policy-gated, exactly like the admin show
57
87
  # screen — and their blind-index `<field>_hash` column is dropped, so the
58
- # equality-leaking hash never goes over the wire. `record.attributes` alone
59
- # would emit the raw ciphertext plus the hash.
88
+ # equality-leaking hash never goes over the wire.
60
89
  def entity_json(record, enrichments: nil)
61
- json = record.attributes
90
+ policy = policy_for(record)
91
+ json = record.attributes.slice(*policy.readable_fields(record.attribute_names))
62
92
 
63
93
  if record.class.respond_to?(:open_loam_encrypted_attributes)
64
94
  record.class.open_loam_encrypted_attributes.each do |name|
65
- json[name] = record.public_send(name)
95
+ json[name] = record.public_send(name) if json.key?(name)
66
96
  json.delete("#{name}_hash")
67
97
  end
68
98
  end
69
99
 
100
+ # `custom_fields` is one JSON column, so it is all-or-nothing at the
101
+ # column level — filter its contents per definition instead.
102
+ if json.key?("custom_fields") && record.class.respond_to?(:custom_field_definitions)
103
+ readable = record.class.custom_field_definitions.map(&:name).select { |n| policy.custom_field_readable?(n) }
104
+ json["custom_fields"] = json["custom_fields"].to_h.slice(*readable)
105
+ end
106
+
70
107
  # Computed cross-module blocks (OpenLoam::Enrichers), under a separate key so
71
108
  # they're never confused with the record's own columns. An index passes the
72
109
  # batched result (avoiding N+1); a single show computes it here.
@@ -2,13 +2,22 @@
2
2
  # watches it live (L-915). Carries tenant + actor explicitly, like every OpenLoam job.
3
3
  # The per-row summary (created/updated/failed + error rows) is stored on the
4
4
  # ProgressJob's `result` for the summary screen.
5
+ #
6
+ # The file arrives as a blob id, never as a job argument. ActiveJob arguments are
7
+ # serialized into the queue backend and echoed in the job log, so passing the CSV
8
+ # itself would persist every row — including columns bound for encrypted fields —
9
+ # in the clear, outside the encryption the destination columns exist to provide.
5
10
  class ImportJob < ApplicationJob
6
11
  queue_as :default
7
12
 
8
- def perform(entity_type:, csv:, mapping:, match_key:, tenant_id:, actor_id:)
13
+ def perform(entity_type:, blob_id:, mapping:, match_key:, tenant_id:, actor_id:)
9
14
  tenant = OpenLoam::Tenant.find(tenant_id)
10
15
  actor = User.find_by(id: actor_id)
11
16
  model = OpenLoam::Import.allowed_model(entity_type)
17
+ blob = ActiveStorage::Blob.find_by(id: blob_id)
18
+ return if blob.nil? # already purged: nothing to import, not an error
19
+
20
+ csv = blob.download.force_encoding("UTF-8")
12
21
 
13
22
  OpenLoam.as_tenant(tenant, actor: actor) do
14
23
  total = [ CSV.parse(csv).size - 1, 0 ].max
@@ -21,5 +30,9 @@ class ImportJob < ApplicationJob
21
30
  progress.fail!(error.message)
22
31
  end
23
32
  end
33
+ ensure
34
+ # The upload has done its job; leaving it behind is a second copy of the
35
+ # same data with none of the destination's protections.
36
+ blob&.purge_later
24
37
  end
25
38
  end
@@ -8,8 +8,11 @@
8
8
  #
9
9
  # OpenLoam::Encryption.master_key = ENV.fetch("OPEN_LOAM_MASTER_KEY")
10
10
  #
11
- # Rotating the key without re-encrypting orphans existing ciphertext — see
12
- # `bin/rails open_loam:encryption:rotate[Model,tenant_id]`.
11
+ # To ROTATE: keep the outgoing key in OPEN_LOAM_PREVIOUS_MASTER_KEY while the new
12
+ # one is in OPEN_LOAM_MASTER_KEY. Reads fall back to the old key, writes always use
13
+ # the new one, so `bin/rails open_loam:encryption:rotate[Model,tenant_id]` can walk
14
+ # each tenant's rows. Drop the previous key once every model has been rotated —
15
+ # until then, both keys are live and the old one is still worth protecting.
13
16
 
14
17
  # Roles every tenant of this app is expected to have. A registry read by your
15
18
  # own seeding/admin code — OpenLoam does not create memberships for you, because
@@ -65,6 +68,15 @@ OpenLoam.locales = %w[en]
65
68
  # nothing reaches the browser unless it matches a pattern here (security posture).
66
69
  OpenLoam.broadcast_events = [ "open_loam.notification.", "open_loam.progress." ]
67
70
 
71
+ # Events EXCLUDED from the event log (OpenLoam::EventLog), which otherwise
72
+ # captures everything. Progress ticks are excluded because a bulk import fires
73
+ # one per row — volume without history worth keeping.
74
+ OpenLoam.uncaptured_events = [ "open_loam.progress." ]
75
+
76
+ # How long captured events are kept before the daily per-tenant prune. nil keeps
77
+ # everything, which means an unbounded table.
78
+ OpenLoam.event_log_retention = 90.days
79
+
68
80
  # Response enrichers (OpenLoam::Enrichers): attach a computed block onto ANOTHER
69
81
  # module's entity in admin/API responses, with no foreign-key coupling — billing
70
82
  # can annotate an Equipment without Equipment knowing billing exists. Pass a
@@ -3,13 +3,13 @@ class CreateOpenLoamApiTokens < ActiveRecord::Migration[<%= ActiveRecord::VERSIO
3
3
  create_table :open_loam_api_tokens<%= open_loam_id_option %> do |t|
4
4
  t.references :tenant, null: false, foreign_key: { to_table: :open_loam_tenants }<%= open_loam_type_option %>
5
5
  t.references :user, null: false, foreign_key: true<%= open_loam_type_option %>
6
- t.string :token, null: false
6
+ t.string :token_digest, null: false # SHA-256; the plaintext is never stored
7
7
  t.string :label
8
8
  t.datetime :last_used_at
9
9
  t.timestamps
10
10
  end
11
11
  # Tokens are looked up before a tenant is known, so the uniqueness that
12
12
  # matters is global.
13
- add_index :open_loam_api_tokens, :token, unique: true
13
+ add_index :open_loam_api_tokens, :token_digest, unique: true
14
14
  end
15
15
  end
@@ -0,0 +1,13 @@
1
+ class CreateOpenLoamEventRecords < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :open_loam_event_records<%= open_loam_id_option %> do |t|
4
+ t.references :tenant, null: false, foreign_key: { to_table: :open_loam_tenants }<%= open_loam_type_option %>
5
+ t.string :name, null: false # "rental.equipment.created"
6
+ t.json :payload, null: false # JSON-scalar event payload, as published
7
+ t.datetime :occurred_at, null: false # publish time, not row-write time
8
+ t.timestamps
9
+ end
10
+ # Matches OpenLoam::EventLog.read: tenant, then name or prefix, in time order.
11
+ add_index :open_loam_event_records, %i[tenant_id name occurred_at]
12
+ end
13
+ end
@@ -6,7 +6,6 @@ class CreateOpenLoamInboundWebhooks < ActiveRecord::Migration[<%= ActiveRecord::
6
6
  t.string :token, null: false # unguessable URL id: /webhooks/:token
7
7
  t.string :secret, null: false # HMAC key (authenticates the call)
8
8
  t.string :signature_header, null: false, default: "X-OpenLoam-Signature"
9
- t.string :delivery_id_header # optional: external delivery-id for dedupe
10
9
  t.string :timestamp_header # optional: enables the freshness window
11
10
  t.integer :timestamp_tolerance # seconds; nil = default 300
12
11
  t.string :event_name, null: false # what to publish on the bus
@@ -11,6 +11,9 @@ class CreateOpenLoamSsoProviders < ActiveRecord::Migration[<%= ActiveRecord::VER
11
11
  t.string :jit_role, null: false, default: "employee" # role for JIT-provisioned users
12
12
  t.json :group_role_map, null: false, default: {} # IdP group -> OpenLoam role (first match wins)
13
13
  t.boolean :active, null: false, default: true
14
+ # Proof of domain ownership, stamped by an operator (rake
15
+ # open_loam:sso:verify_domain) — never by the manager who typed the domain.
16
+ t.datetime :domain_verified_at
14
17
  t.timestamps
15
18
  end
16
19
  # HRD resolves a provider by email domain, cross-tenant, before login — so the
@@ -6,7 +6,7 @@ module OpenLoam
6
6
  #
7
7
  # OpenLoam::AuthThrottle.locked?(email) # refuse if true
8
8
  # OpenLoam::AuthThrottle.record_failure(email, kind: "password", ip: request.ip)
9
- # OpenLoam::AuthThrottle.clear(email) # on success
9
+ # OpenLoam::AuthThrottle.clear(email, kind: "password") # on success
10
10
  #
11
11
  # PER-IDENTIFIER is the primary defense (an attacker targets one account /
12
12
  # code). A per-ip throttle to blunt spraying across accounts is a clean
@@ -46,6 +46,9 @@ module OpenLoam
46
46
 
47
47
  # Locked if there are >= max failures within the window. Old attempts age out
48
48
  # of the window automatically (the window query IS the expiry — no reaper).
49
+ #
50
+ # Both auth call sites ask WITHOUT a kind on purpose: failures on either
51
+ # factor lock both, so grinding TOTP also costs the attacker the password form.
49
52
  def locked?(identifier, kind: nil)
50
53
  recent_failures(identifier, kind: kind) >= max_attempts
51
54
  end
@@ -58,8 +61,13 @@ module OpenLoam
58
61
 
59
62
  # Reset the counter — call on a SUCCESSFUL auth so a legitimate user who
60
63
  # eventually gets in isn't left throttled.
61
- def clear(identifier)
62
- attempts(identifier).delete_all
64
+ #
65
+ # Pass `kind:` — clearing every kind on one factor's success lets an attacker
66
+ # holding the password reset the TOTP counter before each guess.
67
+ def clear(identifier, kind: nil)
68
+ scope = attempts(identifier)
69
+ scope = scope.where(kind: Array(kind).map(&:to_s)) if kind
70
+ scope.delete_all
63
71
  end
64
72
 
65
73
  # Seconds until the identifier unlocks (for the "try again in N" message),