The session models
Every sign-in is one record of a session model.
- A user's session is an
Identities::UserSession, stored inidentities_user_sessions. - An admin's session is an
Identities::AdminSession, stored inidentities_admin_sessions. - The two tables have the same columns. Only the owner column differs.
The columns
Table identities_user_sessions {
id bigint [pk]
uuid string [unique, not null]
identities_user_id bigint [not null, ref: > identities_users.id]
token_digest string [null, unique]
csrf_token string [null]
ip_address string [null]
user_agent string [null]
created_at timestamptz [not null]
updated_at timestamptz [not null]
}
| Column | Why it exists |
|---|---|
id | The value the signed cookie holds. One primary-key read is the whole check |
uuid | Every table in the repo has one. It is the session's identifier in any API response. An API response never shows id |
identities_user_id | The owner. "Sign out everywhere" and a password change find every session of one owner through it. identities_admin_sessions has identities_admin_id instead |
token_digest | The SHA-256 digest of a bearer token. Null on a cookie session. The unique index makes the bearer lookup one indexed read |
csrf_token | The value a cookie-authenticated write must send back in X-CSRF-Token. Null on a token session, which needs none |
ip_address | The caller's address at login, as the Rails authentication generator records it. For support, and for a later "your signed-in devices" page |
user_agent | The caller's browser or app at login. The same use |
created_at | When the sign-in happened. The absolute limit reads it |
updated_at | When the session was last used. The idle limit reads it |
What an Identities::UserSession does not hold:
- No
expires_at. The limits live in configuration, so changing one needs no migration. A session's expiry is computed fromcreated_atandupdated_atwhen it is read. - No raw token. If the table leaks, the digests are useless to the thief, because SHA-256 cannot be reversed.
- Nothing about the person beyond the owner id. Name, email and access rights are read from the owner's own record when the JSON is built.
How Rails finds the session on a request
Every endpoint that needs a signed-in person runs the same lookup first.
| The request carries | What Rails does | Result |
|---|---|---|
A jodapp_session_id cookie whose signature is valid | Identities::UserSession.not_expired.find_by(id: the signed id) | The Identities::UserSession, or 401 when there is none |
A jodapp_session_id cookie whose signature fails | Treats it as no cookie | 401 |
An Authorization: Bearer <token> header | Computes the SHA-256 digest, then Identities::UserSession.not_expired.find_by(token_digest: the digest) | The Identities::UserSession, or 401 |
| Both a cookie and a header | Checks the cookie first, then the header. No client sends both | As above |
| Neither | Nothing to look up | 401 |
After the lookup, Rails keeps two things for the rest of the request.
CurrentRequest.identities_useris the owner, the same attribute the rest of the app already reads.CurrentRequest.identities_user_sessionis theIdentities::UserSession.- Logout destroys it. A password change keeps it and destroys the owner's other sessions.
- The admin concern sets
CurrentRequest.identities_adminandCurrentRequest.identities_admin_sessionthe same way.
The lookup never reads the other identity system's table.
- The user concern only ever queries
identities_user_sessions. - The admin concern only ever queries
identities_admin_sessions. - A wrong-side lookup is impossible, because the code for it does not exist.
The limits
An Identities::UserSession expires in two ways, and the earlier one wins.
- The idle limit: the session was last used too long ago. It reads
updated_at. - The absolute limit: the session was created too long ago. It reads
created_at.
| Identity system | Idle limit | Absolute limit | What the person experiences |
|---|---|---|---|
| Users | 7 days | 30 days | Someone who uses Jod at least weekly stays signed in, and signs in again at most once a month |
| Admins | 1 day | 7 days | An admin signs in again after a full day away, and at least once a week |
The limits live in configuration, per identity system, in config/application.rb:
config.x.sessions = {
identities_user: { idle_limit: 7.days, absolute_limit: 30.days },
identities_admin: { idle_limit: 1.day, absolute_limit: 7.days },
touch_interval: 15.minutes
}
One scope on each model holds the rule, and everything else calls it.
module Identities
class UserSession < ApplicationRecord
scope :not_expired, -> {
limits = Rails.configuration.x.sessions.fetch(:identities_user)
where(updated_at: limits.fetch(:idle_limit).ago..)
.where(created_at: limits.fetch(:absolute_limit).ago..)
}
end
end
- The lookup above uses it, so an expired session is never found.
- The job that deletes expired sessions, below, uses its opposite, so the two can never disagree about which sessions are alive.
Keeping a session alive is one write, and a rare one.
- A request that finds the
Identities::UserSessiontouchesupdated_at, but only when it is older than the touch interval, 15 minutes. - So a person who clicks all day causes four writes an hour, not one per click.
- The bearer path does the same. The limits and the touch belong to the session, not to how it was found.
The jobs that delete expired sessions
Expired sessions are already dead to the lookup. These jobs only keep the tables small.
Identities::DeleteExpiredUserSessionsJobandIdentities::DeleteExpiredAdminSessionsJob, one per table.- Each runs hourly at minute 20, from
config/sidekiq_scheduler.yml, on thelowqueue. - Each job class sets
retry: 0on itself. The scheduler passes only the queue to an ActiveJob class, so aretryline in the yml would be ignored. - Each deletes
not_expired.invert_wherein batches of 1000.
How we know they ran:
- The Sidekiq admin panel's "Recurring Jobs" tab, which sidekiq-scheduler adds, lists both jobs with their cron line, the last time each was enqueued and the next time it will be.
- A job that raises goes to the panel's "Dead" tab on its first failure, because it has no retries, and the exception reaches Sentry through the Sidekiq error handler that already exists.
- Nothing alerts when the scheduler itself stops. The jobs send no Sentry cron check-in: Sentry includes one cron monitor and charges for each further one, and a stopped job is never a security problem (below). A free alert for a stopped scheduler is a later decision, recorded in Identities D5, the row "How we know the cleanup jobs ran".
- There is no log line to watch. A container log is not a control.
A stopped job is never a security problem.
- The lookup enforces the limits on every request.
- The only effect of a stopped job is a table that grows until the job runs again.