standard_id 0.36.2 → 0.38.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 +48 -0
- data/lib/standard_id/config/schema.rb +28 -0
- data/lib/standard_id/events.rb +59 -1
- data/lib/standard_id/oauth/refresh_token_flow.rb +19 -2
- data/lib/standard_id/oauth/token_grant_flow.rb +34 -2
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea8084bb866fc4da82d81ec18d2b24676e3209ac8d21975c20deb9aa334160fc
|
|
4
|
+
data.tar.gz: 1cc922486ca4204c8308ff489dc5f425424c06e758a1a4b77276b6670a637f10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e030d75f3f08d7961ac1cab6ede04da0940ee5471191bfc5c06ef832469f6da2a9af44539fa04e889801defded08c8bdf138c7a8bd1661d8c0628c5b707d08f
|
|
7
|
+
data.tar.gz: b5301f0db610433bcb542699f95beddcd9e91e93dda098f940edafc9af5c70ab264ef6c70b427e84ac34a9edae861316cf3d608f4f5c2dad6996eb6b9780cbcf
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.38.0] - 2026-08-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Event payloads now carry record identifiers alongside the records — the emitter half of rarebit-one/rarebit-ops#296.** This gem publishes whole ActiveRecord objects on the notification bus (`account:`, `current_account:`, `session:`, `code_challenge:`), and a record serialises with **all** its attributes. That is how `account.password_digest`, `session.token_digest`, `session.lookup_hash` and the plaintext passwordless OTP in `code_challenge.code` reached append-only audit rows across the estate.
|
|
15
|
+
|
|
16
|
+
`standard_audit` 0.11.0 closed its own write path with `dereference_record_metadata`, but it could only ever protect `audit_logs`. Every host subscriber receives the same payload and may put the record into Sentry context, a structured log, or a table of its own — all outside that gem's reach. Curating at the emitter is the only fix that closes the class.
|
|
17
|
+
|
|
18
|
+
`config.events.publish_record_identifiers` (default **`true`**) adds `<key>_id` and `<key>_gid` beside every record-valued key and **removes nothing**.
|
|
19
|
+
|
|
20
|
+
- **`config.events.publish_records` (default `true`) is the opt-out** for hosts ready to stop receiving the records themselves.
|
|
21
|
+
|
|
22
|
+
### Notes for hosts
|
|
23
|
+
|
|
24
|
+
- **This release is additive on purpose — no consumer changes behaviour today.** All five consumers configure an `actor_extractor` reading `payload[:actor] || payload[:current_account] || payload[:account]` and calling `to_global_id` on the result, so swapping records for identifiers in one step would break actor attribution in five apps simultaneously.
|
|
25
|
+
|
|
26
|
+
The migration path is per-host and provable: point your subscribers at the new `_gid` keys, confirm in staging that nothing depends on the records, then set `publish_records` to `false`. The intended end state is for that to default to `false` in a later major, and for the records to go away in the one after.
|
|
27
|
+
|
|
28
|
+
- **Both `_id` and `_gid` are published.** A GlobalID carries the class, and `account_id` alone cannot tell an `Account` from anything else sharing a primary key. The audit gems key on the GID.
|
|
29
|
+
|
|
30
|
+
- **`to_global_id` is rescued.** It raises on an unpersisted record, and publishing sits on the authentication path — an event must never fail because of metadata this method adds to it.
|
|
31
|
+
|
|
32
|
+
## [0.37.0] - 2026-08-05
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`refresh_tokens.session_id` is finally populated, and the cause was statement ordering, not a missing feature.** Every refresh token in every consuming app had a nil `session_id`, so every session→refresh revocation cascade — the gem's own `Session#revoke!`, `Session.revoke_sessions!`, and each app's equivalent — matched **zero rows**, and 0.35.0's parent-session check had no parent to consult. Measured in production: jumpdrive 15 tokens / 0 linked; nutripod 238 / 0, with **177 live at the time of measurement**.
|
|
37
|
+
|
|
38
|
+
`generate_token_response` created the refresh token one statement *before* `maybe_persist_session_for_token!` ran, inside the same transaction — so when `persist_refresh_token!` wrote `session_id`, there was no session yet to point at. The link was nil **by construction**, not by omission. Swapping the two statements is the fix; `refresh_token_session_id` now returns the session the same grant just materialised.
|
|
39
|
+
|
|
40
|
+
See rarebit-one/rarebit-ops#304.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **BEHAVIOUR NARROWED — `validate_parent_session!` now refuses only on a REVOKED parent, no longer on an expired one.** This is a deliberate relaxation of what 0.35.0 shipped, and hosts should read it before upgrading.
|
|
45
|
+
|
|
46
|
+
`Session#active?` is `!revoked? && !expired?`, so the original `return if session.active?` also refused a refresh whose parent had merely aged out. That was harmless while nothing was linked — with no parent, neither branch could fire. The moment linkage above becomes real, an expiry branch silently becomes a **lifetime policy**, and a badly-scaled one: an app running an 86400s `browser_session_lifetime` against a 2592000s `refresh_token_lifetime` would see its session-backed clients drop from monthly to **daily** re-authorization, as a side effect of a security fix that was never about login frequency.
|
|
47
|
+
|
|
48
|
+
Revocation is the property the estate wants: an operator revokes a session and access ends. Lifetime remains `refresh_token_lifetime`'s job, which already exists and is already configured per host. Keeping them separate is what lets linkage ship without renegotiating anyone's login cadence.
|
|
49
|
+
|
|
50
|
+
- **`ServiceSession` is carved out of linkage on purpose.** Machine credentials keep their own lifecycle, matching the exclusion the `:account` revocation scope already makes, so a human's browser logout cannot kill a running CLI or MCP agent sharing the account.
|
|
51
|
+
|
|
52
|
+
### Notes for hosts
|
|
53
|
+
|
|
54
|
+
**This release does not retroactively repair your app.** Linkage happens only where the host materialises a session at token-issue time via `config.session.session_type_resolver`. A host with no resolver configured keeps nil `session_id` and is unchanged in every respect. The gem now makes the property *achievable and automatic*; enabling it per app is separate work.
|
|
55
|
+
|
|
56
|
+
Existing rows cannot be linked retroactively — there is no record of which session issued them.
|
|
57
|
+
|
|
10
58
|
## [0.36.2] - 2026-08-04
|
|
11
59
|
|
|
12
60
|
**The third release in one chain, and the last of it.** 0.36.0 made the account guards bite on live sessions; 0.36.1 repaired the 500 that exposed on `ApiEngine`; this repairs 0.36.1's own overreach onto the OAuth token endpoint. That reads like flailing, so state it plainly: 0.36.0 was a correct behaviour change, and each follow-up is a narrower blast radius of the same root cause — an exception pair that no controller in the ancestry had ever had to answer for, meeting two route families with two different wire contracts. 0.36.1 fixed the first family and, by rescuing on the shared parent, silently annexed the second. 0.36.2 draws the line between them. Nothing here reverts 0.36.0 or 0.36.1; resource-endpoint behaviour is byte-identical to 0.36.1.
|
|
@@ -108,6 +108,34 @@ StandardId::ConfigSchema.define do
|
|
|
108
108
|
|
|
109
109
|
scope :events do
|
|
110
110
|
field :enable_logging, type: :boolean, default: false
|
|
111
|
+
|
|
112
|
+
# Publish `<key>_id` / `<key>_gid` alongside every record-valued payload key
|
|
113
|
+
# (rarebit-one/rarebit-ops#296).
|
|
114
|
+
#
|
|
115
|
+
# StandardId publishes whole ActiveRecord objects on the notification bus —
|
|
116
|
+
# `account:`, `current_account:`, `session:`, `code_challenge:` — and a
|
|
117
|
+
# record serialises with ALL its attributes. `standard_audit` 0.11.0 stopped
|
|
118
|
+
# that reaching `audit_logs`, but the bus is not the audit gem's to police:
|
|
119
|
+
# a host's own subscribers still receive the full record and may put it in
|
|
120
|
+
# Sentry context, a structured log, or a table of their own.
|
|
121
|
+
#
|
|
122
|
+
# On by default because it only ADDS keys. Nothing that reads `account:`
|
|
123
|
+
# today changes behaviour — including every consumer's `actor_extractor`,
|
|
124
|
+
# which reads the record to call `to_global_id` on it.
|
|
125
|
+
field :publish_record_identifiers, type: :boolean, default: true
|
|
126
|
+
|
|
127
|
+
# The other half of the migration, and deliberately OFF by default.
|
|
128
|
+
#
|
|
129
|
+
# Turning it off stops the records themselves being published, leaving only
|
|
130
|
+
# the identifiers. That IS a breaking payload change — it is the whole point
|
|
131
|
+
# — so it is a host's opt-in, taken once its subscribers read the `_id`/`_gid`
|
|
132
|
+
# keys instead. A host can flip it in staging to prove nothing depends on
|
|
133
|
+
# the records before committing.
|
|
134
|
+
#
|
|
135
|
+
# The intended end state is this defaulting to false in a later major, and
|
|
136
|
+
# the records going away in the one after. Until a host opts in, behaviour
|
|
137
|
+
# is exactly as it was.
|
|
138
|
+
field :publish_records, type: :boolean, default: true
|
|
111
139
|
end
|
|
112
140
|
|
|
113
141
|
scope :passwordless do
|
data/lib/standard_id/events.rb
CHANGED
|
@@ -132,7 +132,65 @@ module StandardId
|
|
|
132
132
|
enriched[:scope] ||= ::Current.scope if ::Current.respond_to?(:scope) && ::Current.scope.present?
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
-
enriched.merge(payload)
|
|
135
|
+
with_record_identifiers(enriched.merge(payload))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Adds `<key>_id` and `<key>_gid` next to every record-valued key, and —
|
|
139
|
+
# only when a host opts in — removes the records themselves.
|
|
140
|
+
#
|
|
141
|
+
# WHY (rarebit-one/rarebit-ops#296)
|
|
142
|
+
# ---------------------------------
|
|
143
|
+
# This gem publishes whole ActiveRecord objects: `account:`,
|
|
144
|
+
# `current_account:`, `session:`, `code_challenge:`. A record serialises
|
|
145
|
+
# with ALL its attributes, which is how `account.password_digest`,
|
|
146
|
+
# `session.token_digest`, `session.lookup_hash` and the plaintext
|
|
147
|
+
# passwordless OTP in `code_challenge.code` reached append-only audit rows
|
|
148
|
+
# — 3,007 of them on one app, unrepairable because the table refuses
|
|
149
|
+
# UPDATE by design.
|
|
150
|
+
#
|
|
151
|
+
# `standard_audit` 0.11.0 fixed its own write path. It cannot fix the bus:
|
|
152
|
+
# every host subscriber still receives the full record and may put it
|
|
153
|
+
# anywhere. Publishing the identifier alongside is what lets a subscriber
|
|
154
|
+
# stop needing the record at all.
|
|
155
|
+
#
|
|
156
|
+
# WHY ADDITIVE FIRST
|
|
157
|
+
# ------------------
|
|
158
|
+
# Swapping records for identifiers in one step breaks every consumer's
|
|
159
|
+
# `actor_extractor` simultaneously — all five apps configure one, and they
|
|
160
|
+
# read `payload[:actor] || payload[:current_account] || payload[:account]`
|
|
161
|
+
# expecting something they can call `to_global_id` on. So the default adds
|
|
162
|
+
# keys and removes nothing; `config.events.publish_records = false` is the
|
|
163
|
+
# opt-out a host takes once its subscribers read the identifiers.
|
|
164
|
+
#
|
|
165
|
+
# `_gid` as well as `_id` because a GlobalID carries the class, and the
|
|
166
|
+
# audit gems key on it. `to_global_id` is rescued: a new or unpersisted
|
|
167
|
+
# record raises, and an event must never fail because of the metadata this
|
|
168
|
+
# method adds to it.
|
|
169
|
+
def with_record_identifiers(payload)
|
|
170
|
+
return payload unless defined?(::ActiveRecord::Base)
|
|
171
|
+
|
|
172
|
+
config = StandardId.config.events
|
|
173
|
+
return payload unless config.publish_record_identifiers || !config.publish_records
|
|
174
|
+
|
|
175
|
+
payload.each_with_object({}) do |(key, value), out|
|
|
176
|
+
unless value.is_a?(::ActiveRecord::Base)
|
|
177
|
+
out[key] = value
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
out[key] = value if config.publish_records
|
|
182
|
+
|
|
183
|
+
next unless config.publish_record_identifiers
|
|
184
|
+
|
|
185
|
+
out[:"#{key}_id"] ||= value.id
|
|
186
|
+
out[:"#{key}_gid"] ||= safe_global_id(value)
|
|
187
|
+
end.compact
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def safe_global_id(record)
|
|
191
|
+
record.to_global_id.to_s
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
136
194
|
end
|
|
137
195
|
end
|
|
138
196
|
end
|
|
@@ -84,9 +84,26 @@ module StandardId
|
|
|
84
84
|
#
|
|
85
85
|
# Checking the parent here makes the property true by construction: it
|
|
86
86
|
# holds however the session was revoked — `revoke!`, a bare `update!`, a
|
|
87
|
-
# bulk `update_all`, a DBA, a data fix
|
|
87
|
+
# bulk `update_all`, a DBA, a data fix.
|
|
88
88
|
# See rarebit-one/rarebit-ops#297.
|
|
89
89
|
#
|
|
90
|
+
# REVOCATION, NOT EXPIRY — deliberately, and this is a narrowing of what
|
|
91
|
+
# 0.35.0 shipped. `Session#active?` is `!revoked? && !expired?`, so the
|
|
92
|
+
# original `return if session.active?` also refused a refresh whose parent
|
|
93
|
+
# had merely aged out. That was harmless while `session_id` was nil
|
|
94
|
+
# everywhere; the moment rarebit-one/rarebit-ops#304 makes linkage real it
|
|
95
|
+
# silently becomes a lifetime policy — and a badly-scaled one. jumpdrive
|
|
96
|
+
# runs an 86400s browser_session_lifetime against a 2592000s
|
|
97
|
+
# refresh_token_lifetime, so tying the two would cut its session-backed
|
|
98
|
+
# MCP clients from monthly to DAILY re-authorization, as a side effect of
|
|
99
|
+
# a security fix nobody asked to change login frequency.
|
|
100
|
+
#
|
|
101
|
+
# Revocation is the property the estate actually wants: an operator clicks
|
|
102
|
+
# "revoke this session" and access ends. Expiry is a lifetime decision that
|
|
103
|
+
# belongs to `refresh_token_lifetime`, which already exists and is already
|
|
104
|
+
# configured per app. Keeping them separate means linkage can ship without
|
|
105
|
+
# renegotiating anyone's login cadence.
|
|
106
|
+
#
|
|
90
107
|
# A refresh token with no session (`session_id` nil) is unaffected: that
|
|
91
108
|
# is the machine-to-machine shape (client_credentials and any other grant
|
|
92
109
|
# the gem issues without persisting a session), where there is no parent
|
|
@@ -99,7 +116,7 @@ module StandardId
|
|
|
99
116
|
def validate_parent_session!
|
|
100
117
|
session = @current_refresh_token_record.session
|
|
101
118
|
return if session.nil?
|
|
102
|
-
return
|
|
119
|
+
return unless session.revoked?
|
|
103
120
|
|
|
104
121
|
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
105
122
|
end
|
|
@@ -63,8 +63,15 @@ module StandardId
|
|
|
63
63
|
# (see there for why) — that path is safe because the swallowed
|
|
64
64
|
# exception fires before any DB work in this block.
|
|
65
65
|
ActiveRecord::Base.transaction do
|
|
66
|
+
# Session FIRST, refresh token second. The order is load-bearing, not
|
|
67
|
+
# stylistic: `persist_refresh_token!` writes `session_id`, so the
|
|
68
|
+
# session row has to exist before the token row is built or the link
|
|
69
|
+
# is nil by construction. It was the other way round until
|
|
70
|
+
# rarebit-one/rarebit-ops#304, which is the whole reason
|
|
71
|
+
# `refresh_tokens.session_id` was nil in every app in the estate —
|
|
72
|
+
# there was no session to point at yet.
|
|
73
|
+
@oauth_session = maybe_persist_session_for_token!
|
|
66
74
|
response[:refresh_token] = generate_refresh_token if supports_refresh_token?
|
|
67
|
-
maybe_persist_session_for_token!
|
|
68
75
|
end
|
|
69
76
|
|
|
70
77
|
emit_token_issued(expires_in)
|
|
@@ -166,8 +173,33 @@ module StandardId
|
|
|
166
173
|
)
|
|
167
174
|
end
|
|
168
175
|
|
|
176
|
+
# The parent session for the refresh token issued by THIS grant, when the
|
|
177
|
+
# host materialised one via `config.session.session_type_resolver`.
|
|
178
|
+
#
|
|
179
|
+
# Returning a real id here is what makes "revoking a session ends that
|
|
180
|
+
# session's access" true by construction rather than an emergent property
|
|
181
|
+
# of every caller remembering to reach for the right method. With it nil —
|
|
182
|
+
# which it was everywhere before rarebit-one/rarebit-ops#304 — every
|
|
183
|
+
# session→refresh cascade in the gem and in the apps matched zero rows,
|
|
184
|
+
# and 0.35.0's parent-session check had no parent to consult.
|
|
185
|
+
#
|
|
186
|
+
# Still nil where there is genuinely no parent: no resolver configured
|
|
187
|
+
# (the default), or a grant the host does not materialise a session for.
|
|
188
|
+
# That keeps machine-to-machine grants (client_credentials) exactly as
|
|
189
|
+
# they were — there is no session to outlive.
|
|
190
|
+
#
|
|
191
|
+
# ServiceSessions are carved out on purpose. Machine credentials have
|
|
192
|
+
# their own lifecycle, and the gem's `:account` revocation scope already
|
|
193
|
+
# excludes them; linking one here would let a human's browser logout kill
|
|
194
|
+
# a running CLI or MCP agent sharing the account. `OauthSessionPersistence`
|
|
195
|
+
# refuses to build one at all, so this guard is belt-and-braces against a
|
|
196
|
+
# host that reaches around it — cheap, and the failure it prevents is
|
|
197
|
+
# silent.
|
|
169
198
|
def refresh_token_session_id
|
|
170
|
-
nil
|
|
199
|
+
return nil if @oauth_session.nil?
|
|
200
|
+
return nil if @oauth_session.is_a?(StandardId::ServiceSession)
|
|
201
|
+
|
|
202
|
+
@oauth_session.id
|
|
171
203
|
end
|
|
172
204
|
|
|
173
205
|
def previous_refresh_token_record
|
data/lib/standard_id/version.rb
CHANGED