sessions 0.1.3 → 0.2.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.
@@ -8,10 +8,12 @@ module Sessions
8
8
  # explicitly. Either way, ALL gem logic lives here, so the host's model
9
9
  # file never goes stale.
10
10
  #
11
- # One mental model: **rows = active sessions; events = history.** A row is
12
- # destroyed on logout/revocation/expiry (instant remote revocation the
13
- # same omakase semantics as Rails 8.1's own password-reset destroy_all),
14
- # and its tombstone lives in the `sessions_events` trail.
11
+ # One mental model: **rows = session lifecycle state; events = audit
12
+ # history.** `ended_at: nil` means the device is live. Ending a row is an
13
+ # explicit state transition (`ended_reason`) and never relies on deleting a
14
+ # row plus later interpreting the absence. That distinction matters in
15
+ # Devise/Warden mode, where the gem decorates the host session and must
16
+ # never kick a user unless a durable explicit ending says so.
15
17
  #
16
18
  # The three lifecycle callbacks observe 100% of both adapters' flows:
17
19
  #
@@ -20,7 +22,8 @@ module Sessions
20
22
  # method, geolocate (when free).
21
23
  # after_create_commit — write the `login` event, detect new devices,
22
24
  # enforce the per-user cap, enqueue async geo.
23
- # after_destroy_commit — write the `logout`/`revoked`/`expired` event.
25
+ # after_destroy_commit — best-effort compatibility for host-side deletes
26
+ # (account erasure, Rails generator destroy_all).
24
27
  #
25
28
  # Every callback body is error-isolated: a parsing/geo/event failure may
26
29
  # lose a log row; it may NEVER break a sign-in.
@@ -50,9 +53,9 @@ module Sessions
50
53
  end
51
54
  end
52
55
 
53
- # Transient revocation context: set by `revoke!` (and the adapters'
54
- # logout labeling) before the row is destroyed, read by the
55
- # after_destroy_commit event writer.
56
+ # Transient direct-delete context: legacy/host destroy paths can still
57
+ # label their compatibility event. Normal gem APIs call `end!` and keep
58
+ # the row as durable lifecycle state.
56
59
  attr_accessor :revocation_reason, :revoked_by
57
60
 
58
61
  # Set on adopted rows (sessions that predate the gem) so adoption
@@ -61,24 +64,30 @@ module Sessions
61
64
 
62
65
  before_create :sessions_enrich
63
66
  after_create_commit :sessions_record_login
64
- after_destroy_commit :sessions_record_end
67
+ after_destroy_commit :sessions_record_destroyed_end
65
68
 
66
69
  scope :by_recency, lambda {
67
70
  # COALESCE keeps never-touched sessions ordered by creation time,
68
71
  # portably across sqlite/postgres/mysql.
69
72
  order(Arel.sql("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) DESC"))
70
73
  }
74
+ scope :live, lambda {
75
+ column_names.include?("ended_at") ? where(ended_at: nil) : all
76
+ }
77
+ scope :ended, lambda {
78
+ column_names.include?("ended_at") ? where.not(ended_at: nil) : none
79
+ }
71
80
  scope :active, lambda {
72
- where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) >= ?", INACTIVE_AFTER.ago)
81
+ live.where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) >= ?", INACTIVE_AFTER.ago)
73
82
  }
74
83
  scope :inactive, lambda {
75
- where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) < ?", INACTIVE_AFTER.ago)
84
+ live.where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) < ?", INACTIVE_AFTER.ago)
76
85
  }
77
86
  end
78
87
 
79
88
  class_methods do
80
89
  # The user-facing trail rows linked to this registry (`session_id` is
81
- # a plain column, no FK — history must survive row destruction).
90
+ # a plain column, no FK — history must survive lifecycle-row cleanup).
82
91
  def sessions_events_for(session_id)
83
92
  Sessions::Event.where(session_id: session_id)
84
93
  end
@@ -137,15 +146,35 @@ module Sessions
137
146
 
138
147
  # --- Revocation -----------------------------------------------------------
139
148
 
140
- # Destroy this session remote logout, effective on that device's very
141
- # next request (both adapters validate liveness per request). Writes a
142
- # `revoked` (or `expired`) event with the reason and actor, rotates the
143
- # user's remember-me credentials in Devise mode (config.revoke_remember_me),
144
- # and fires the on_session_revoked hook.
149
+ # End this registry row without deleting it. This is the core v0.2
150
+ # invariant: the row itself is the durable liveness state, and
151
+ # `sessions_events` remains a read-only audit trail.
152
+ def end!(reason:, by: nil, at: Time.current, metadata: nil, notify: true)
153
+ reason = Sessions::EndReason.normalize(reason)
154
+ event = nil
155
+ ended_now = false
156
+
157
+ with_lock do
158
+ unless ended?
159
+ sessions_assign_end_state(reason: reason, by: by, at: at, metadata: metadata)
160
+ save!
161
+ ended_now = true
162
+ event = sessions_record_end_event!(reason: reason, by: by, notify: false)
163
+ end
164
+ end
165
+
166
+ Sessions.notify_event(event) if notify && event
167
+ @sessions_ended_now = ended_now
168
+ self
169
+ end
170
+
171
+ # End this session as an explicit revocation/expiry action. In Devise mode
172
+ # the next request still proves possession of the per-row token before the
173
+ # adapter kicks; in Rails-8 auth mode the row id in the signed cookie now
174
+ # resolves to an ended row and the controller hook refuses it.
145
175
  def revoke!(reason: :user_revoked, by: nil)
146
- self.revocation_reason = reason
147
- self.revoked_by = by
148
- destroy!
176
+ end!(reason: reason, by: by)
177
+ return self unless @sessions_ended_now
149
178
 
150
179
  Sessions.safely("revoke_remember_me") { sessions_forget_remember_me! } if Sessions.config.revoke_remember_me
151
180
  Sessions.safely("on_session_revoked hook") do
@@ -159,6 +188,8 @@ module Sessions
159
188
 
160
189
  # Opt-in expiry — false unless the host configured timeouts.
161
190
  def sessions_expired?(now = Time.current)
191
+ return false if ended?
192
+
162
193
  config = Sessions.config
163
194
  activity = last_active_at
164
195
  return true if config.idle_timeout && activity && activity < now - config.idle_timeout
@@ -167,6 +198,18 @@ module Sessions
167
198
  false
168
199
  end
169
200
 
201
+ def live?
202
+ !sessions_column?("ended_at") || try(:ended_at).blank?
203
+ end
204
+
205
+ def ended?
206
+ !live?
207
+ end
208
+
209
+ def sessions_kicks_on_resume?
210
+ ended? && Sessions::EndReason.kicks_on_resume?(try(:ended_reason))
211
+ end
212
+
170
213
  # The throttled last-seen touch: at most one write per
171
214
  # config.touch_every per session, issued as a single conditional UPDATE
172
215
  # (hot-row-safe under concurrent requests, callback-free, and it also
@@ -176,6 +219,7 @@ module Sessions
176
219
  every = Sessions.config.touch_every
177
220
  return false unless every
178
221
  return false unless sessions_column?("last_seen_at")
222
+ return false if ended?
179
223
 
180
224
  now = Time.current
181
225
  threshold = now - every
@@ -331,6 +375,24 @@ module Sessions
331
375
  false
332
376
  end
333
377
 
378
+ def sessions_assign_end_state(reason:, by:, at:, metadata:)
379
+ sessions_assign_force(ended_at: at, ended_reason: reason)
380
+ sessions_assign_force(ended_by_type: by.class.name) if by && sessions_column?("ended_by_type")
381
+ sessions_assign_force(ended_by_id: by.id) if by.respond_to?(:id) && sessions_column?("ended_by_id")
382
+
383
+ payload = metadata.presence || sessions_revoked_by_metadata(by)
384
+ sessions_assign_force(ended_metadata: payload) if payload && sessions_column?("ended_metadata")
385
+ end
386
+
387
+ def sessions_assign_force(attributes)
388
+ attributes.each do |column, value|
389
+ name = column.to_s
390
+ next unless sessions_column?(name)
391
+
392
+ self[name] = value
393
+ end
394
+ end
395
+
334
396
  # --- after_create_commit: the login event ------------------------------------
335
397
 
336
398
  def sessions_record_login
@@ -361,8 +423,8 @@ module Sessions
361
423
  auth_provider: try(:auth_provider),
362
424
  auth_detail: try(:auth_detail).presence,
363
425
  # The browser-continuity id rides the trail too: it's what lets
364
- # Sessions.last_login answer "how did this browser last sign
365
- # in" AFTER logout destroys the row (the "Last used" badge).
426
+ # Sessions.last_login answer "how did this browser last sign in"
427
+ # after logout ends the row (the "Last used" badge).
366
428
  device_id: try(:device_id).presence,
367
429
  metadata: new_device ? { "new_device" => true } : nil
368
430
  )
@@ -399,17 +461,16 @@ module Sessions
399
461
  end
400
462
 
401
463
  # The dedup half of browser continuity: prior live rows for the SAME
402
- # user on the SAME browser install are superseded by this login. A
403
- # quiet destroy on purpose — no on_session_revoked hook, no
404
- # remember-me rotation, no trail event (this is housekeeping, not a
405
- # security event). Scoped to the user: a shared computer with two
406
- # accounts keeps both rows.
464
+ # user on the SAME browser install are superseded by this login. This is
465
+ # a quiet lifecycle end on purpose — no on_session_revoked hook, no
466
+ # remember-me rotation, no trail event (housekeeping, not a security
467
+ # event). Scoped to the user: a shared computer with two accounts keeps
468
+ # both rows.
407
469
  def sessions_supersede_previous_rows!
408
470
  return if try(:device_id).blank?
409
471
 
410
- self.class.where(user: user, device_id: device_id).where.not(id: id).each do |row|
411
- row.revocation_reason = :superseded
412
- row.destroy
472
+ self.class.live.where(user: user, device_id: device_id).where.not(id: id).each do |row|
473
+ row.end!(reason: :superseded, notify: false)
413
474
  end
414
475
  end
415
476
 
@@ -420,7 +481,7 @@ module Sessions
420
481
  cap = Sessions.config.max_sessions_per_user
421
482
  return unless cap
422
483
 
423
- siblings = self.class.where(user: user)
484
+ siblings = self.class.live.where(user: user)
424
485
  overflow = siblings.count - cap
425
486
  return unless overflow.positive?
426
487
 
@@ -429,46 +490,50 @@ module Sessions
429
490
  end
430
491
  end
431
492
 
432
- # --- after_destroy_commit: the end-of-session event ---------------------------
493
+ # --- after_destroy_commit: legacy/direct-delete compatibility -----------------
433
494
 
434
- def sessions_record_end
435
- Sessions.safely("record_end") do
436
- # Account deletion: dependent-destroyed rows of a destroyed owner
437
- # write no events (their trail is erased with them — GDPR default).
438
- next if user.nil? || user.destroyed?
495
+ def sessions_record_destroyed_end
496
+ return if ended?
439
497
 
440
- reason = revocation_reason&.to_sym
441
- next if reason == :superseded
442
-
443
- event_name = case reason
444
- when :logout then "logout"
445
- when :expired then "expired"
446
- else "revoked"
447
- end
448
-
449
- Sessions::Event.record!(
450
- sessions_event_identity_attributes.merge(
451
- event: event_name,
452
- revoked_reason: (event_name == "revoked" ? (reason || :unknown) : nil),
453
- metadata: sessions_revoked_by_metadata
454
- )
455
- )
498
+ Sessions.safely("record_end") do
499
+ # Account deletion: dependent-destroyed rows of a destroyed owner write
500
+ # no events (their trail is erased with them — GDPR default). Direct
501
+ # host deletes are otherwise treated as legacy explicit ends; v0.2's
502
+ # public APIs call `end!`/`revoke!` and preserve the row.
503
+ sessions_record_end_event!(reason: revocation_reason || :unknown, by: revoked_by)
456
504
  end
457
505
  end
458
506
 
459
- def sessions_revoked_by_metadata
460
- return nil unless revoked_by
507
+ def sessions_record_end_event!(reason: nil, by: nil, notify: true)
508
+ return if user.nil? || user.destroyed?
509
+
510
+ reason = Sessions::EndReason.normalize(reason || revocation_reason)
511
+ event_name = Sessions::EndReason.event_for(reason)
512
+ return unless event_name
513
+
514
+ Sessions::Event.record_strict!(
515
+ sessions_event_identity_attributes.merge(
516
+ event: event_name,
517
+ revoked_reason: Sessions::EndReason.revoked_reason_for(reason),
518
+ metadata: sessions_revoked_by_metadata(by || revoked_by)
519
+ ),
520
+ notify: notify
521
+ )
522
+ end
523
+
524
+ def sessions_revoked_by_metadata(actor = revoked_by)
525
+ return nil unless actor
461
526
 
462
- label = if revoked_by.respond_to?(:id) && revoked_by.class.respond_to?(:name)
463
- "#{revoked_by.class.name}##{revoked_by.id}"
527
+ label = if actor.respond_to?(:id) && actor.class.respond_to?(:name)
528
+ "#{actor.class.name}##{actor.id}"
464
529
  else
465
- revoked_by.to_s
530
+ actor.to_s
466
531
  end
467
532
  { "revoked_by" => label }
468
533
  end
469
534
 
470
535
  # The row's identity, copied onto every event it produces — the trail
471
- # must describe the device even after the row is gone.
536
+ # must describe the device even after the row is ended or later erased.
472
537
  def sessions_event_identity_attributes
473
538
  {
474
539
  session_id: id,
@@ -3,13 +3,14 @@
3
3
  module Sessions
4
4
  # The append-only login-activity trail: every successful AND failed login,
5
5
  # logout, revocation and expiry — with attempted identity, device, geo,
6
- # and the linkage no prior art has: `session_id` points at the live
6
+ # and the linkage no prior art has: `session_id` points at the lifecycle
7
7
  # registry row the event created (or ended), so a suspicious login in the
8
8
  # trail is one click away from revoking the session it started.
9
9
  #
10
- # `session_id` is a plain column with NO foreign key on purpose: registry
11
- # rows get destroyed on revoke/logout (rows = active sessions); history
12
- # must survive them.
10
+ # `session_id` is a plain column with NO foreign key on purpose: hosts may
11
+ # still hard-delete rows for account erasure or legacy Rails destroy_all
12
+ # flows, and history must survive that. Normal v0.2 logout/revocation ends
13
+ # the row in place and records an event as audit, not liveness state.
13
14
  #
14
15
  # Rows are written through one tolerant pipeline (`.record!`): unknown
15
16
  # attributes are dropped instead of raising, so hosts can add or remove
@@ -85,20 +86,26 @@ module Sessions
85
86
  # tees the event into `config.events`. Returns the Event or nil —
86
87
  # never raises into a login.
87
88
  def record!(attributes)
88
- Sessions.safely("event") do
89
- event = new
90
- attributes.each do |name, value|
91
- next if value.nil?
92
-
93
- event.try(:"#{name}=", value)
94
- end
95
- event.identity = normalize_identity(event.try(:identity))
96
- clamp_string_columns!(event)
97
- event.save!
98
-
99
- Sessions.notify_event(event)
100
- event
89
+ Sessions.safely("event") { record_strict!(attributes) }
90
+ end
91
+
92
+ # Same tolerant attribute pipeline as `record!`, but persistence errors
93
+ # bubble. `Session#end!` writes lifecycle state and its matching audit
94
+ # event in one transaction; if the event cannot be written, the row must
95
+ # stay live rather than silently losing the audit trail.
96
+ def record_strict!(attributes, notify: true)
97
+ event = new
98
+ attributes.each do |name, value|
99
+ next if value.nil?
100
+
101
+ event.try(:"#{name}=", value)
101
102
  end
103
+ event.identity = normalize_identity(event.try(:identity))
104
+ clamp_string_columns!(event)
105
+ event.save!
106
+
107
+ Sessions.notify_event(event) if notify
108
+ event
102
109
  end
103
110
 
104
111
  # Clamp string columns to their limits BEFORE the insert: the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/sessions.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "sessions/version"
14
14
  require_relative "sessions/errors"
15
15
  require_relative "sessions/configuration"
16
16
  require_relative "sessions/current"
17
+ require_relative "sessions/end_reason"
17
18
  require_relative "sessions/ip_address"
18
19
  require_relative "sessions/device"
19
20
  require_relative "sessions/classifier"
@@ -36,7 +37,7 @@ require_relative "sessions/engine" if defined?(::Rails::Engine)
36
37
  # Sessions.configure { |config| ... } # one block, in an initializer
37
38
  # has_sessions # on your auth model
38
39
  #
39
- # current_user.sessions.active # live devices
40
+ # current_user.sessions.live # live devices
40
41
  # session.device_name # => "Chrome on macOS"
41
42
  # session.revoke! # remote logout, effective next request
42
43
  # current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
@@ -245,7 +246,7 @@ module Sessions
245
246
  }
246
247
  end
247
248
 
248
- # Right-to-erasure helper: destroy every live session, delete the trail,
249
+ # Right-to-erasure helper: destroy every session row, delete the trail,
249
250
  # and null the typed identity on any retained failure rows that match
250
251
  # +user+'s email — so honoring a GDPR deletion request is one call.
251
252
  def forget(user, identity: nil)
@@ -327,11 +328,11 @@ module Sessions
327
328
  request.session.to_hash.each do |key, value|
328
329
  next unless key.to_s.match?(pattern) && value.is_a?(Hash)
329
330
 
330
- id, token = value[Adapters::Warden::SESSION_KEY]
331
- next unless id && token
331
+ tracking = Adapters::Warden.parse_tracking_state(value[Adapters::Warden::SESSION_KEY])
332
+ next unless tracking && tracking[:mode] == "credential"
332
333
 
333
- row = session_model.find_by(id: id)
334
- return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(token)
334
+ row = session_model.live.find_by(id: tracking[:id])
335
+ return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(tracking[:token])
335
336
  end
336
337
  nil
337
338
  rescue StandardError
@@ -342,7 +343,7 @@ module Sessions
342
343
  return nil unless request.respond_to?(:cookie_jar)
343
344
 
344
345
  id = request.cookie_jar.signed[:session_id]
345
- session_model.find_by(id: id) if id
346
+ session_model.live.find_by(id: id) if id
346
347
  rescue StandardError
347
348
  nil
348
349
  end
@@ -374,12 +375,12 @@ module Sessions
374
375
  threshold = idle.ago
375
376
  # A session's last activity is its throttled touch when present,
376
377
  # else its creation.
377
- scopes << session_model.where(last_seen_at: ...threshold)
378
- scopes << session_model.where(last_seen_at: nil).where(created_at: ...threshold)
378
+ scopes << session_model.live.where(last_seen_at: ...threshold)
379
+ scopes << session_model.live.where(last_seen_at: nil).where(created_at: ...threshold)
379
380
  end
380
381
 
381
382
  if (lifetime = config.max_session_lifetime)
382
- scopes << session_model.where(created_at: ...lifetime.ago)
383
+ scopes << session_model.live.where(created_at: ...lifetime.ago)
383
384
  end
384
385
 
385
386
  # NOTE: reduce(:or), never `none.or(...)` — a NullRelation stays null
@@ -398,10 +399,10 @@ module Sessions
398
399
  owner_keys = session_model.column_names.include?("user_type") ? %i[user_type user_id] : %i[user_id]
399
400
 
400
401
  count = 0
401
- session_model.group(*owner_keys).count.each do |owner_key, sessions_count|
402
+ session_model.live.group(*owner_keys).count.each do |owner_key, sessions_count|
402
403
  next if sessions_count <= cap
403
404
 
404
- session_model.where(owner_keys.zip(Array(owner_key)).to_h)
405
+ session_model.live.where(owner_keys.zip(Array(owner_key)).to_h)
405
406
  .order(created_at: :asc)
406
407
  .limit(sessions_count - cap)
407
408
  .each do |session|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -148,7 +148,7 @@ files:
148
148
  - config/locales/es.yml
149
149
  - config/routes.rb
150
150
  - docs/PRD.md
151
- - docs/research/01-carhey.md
151
+ - docs/research/01-host-app.md
152
152
  - docs/research/02-ecosystem.md
153
153
  - docs/research/03-rails-core.md
154
154
  - docs/research/04-devise-warden.md
@@ -165,6 +165,7 @@ files:
165
165
  - lib/generators/sessions/madmin_generator.rb
166
166
  - lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb
167
167
  - lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb
168
+ - lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb
168
169
  - lib/generators/sessions/templates/add_sessions_columns.rb.erb
169
170
  - lib/generators/sessions/templates/create_sessions.rb.erb
170
171
  - lib/generators/sessions/templates/create_sessions_events.rb.erb
@@ -185,6 +186,7 @@ files:
185
186
  - lib/sessions/configuration.rb
186
187
  - lib/sessions/current.rb
187
188
  - lib/sessions/device.rb
189
+ - lib/sessions/end_reason.rb
188
190
  - lib/sessions/engine.rb
189
191
  - lib/sessions/errors.rb
190
192
  - lib/sessions/geolocation.rb