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
|
@@ -37,3 +37,26 @@ audit trail. Send it as <code>Authorization: Bearer <token></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
|
|
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,
|
|
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
|
|
@@ -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
|
-
|
|
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[<id>]</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
|
-
#
|
|
53
|
-
|
|
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.
|
|
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
|
-
|
|
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:,
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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 :
|
|
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, :
|
|
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
|
data/lib/generators/open_loam/install/templates/migrations/create_open_loam_inbound_webhooks.rb
CHANGED
|
@@ -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
|
data/lib/generators/open_loam/install/templates/migrations/create_open_loam_sso_providers.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
62
|
-
|
|
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),
|