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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48bb3940900ff3d8ee4f6a8dc499779b3d00ed9ff37ceeab4849d36662377a47
|
|
4
|
+
data.tar.gz: 65440f973fc99d793ae2439cb1a1b373cd7f79d41c82398fd8d59c6e4dcba784
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
140
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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)
|
|
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.
|
|
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
|
|
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
|
-
<%%
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|