strongroom-rails 0.3.0 → 0.4.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 +7 -0
- data/README.md +52 -13
- data/lib/strongroom/client.rb +90 -4
- data/lib/strongroom/errors.rb +9 -1
- data/lib/strongroom/rails/account.rb +15 -0
- data/lib/strongroom/rails/version.rb +1 -1
- data/lib/strongroom/session.rb +14 -0
- 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: 5561c0010955953673794759017eb0f3683d838d60dd70ed09ae88f185954717
|
|
4
|
+
data.tar.gz: 0d620abade30695c4779dc8e123c9444b4ff723904e3bc19bd903985f1f47d53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa14aa3c3c14336f886876097bb85afb3c56332583567eadbf88b3839abbf518e8af4841a80023d0085ad5e7df6b58a9dff0962c875b0a028751dd4ff6c42046
|
|
7
|
+
data.tar.gz: 333bce6b18f73ec77edbe98645668cd40bcbcaa3443eff5e501706c4e915aee4c18c1808298022222622bf4708cf80f21dd4f6e0923aaa0fc03aded6e51dac61
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.3.0...strongroom-rails-v0.4.0) (2026-10-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add account suspension and session revocation, service imports with conditional password replacement and in-daemon pruning; recover only verified identities ([be52bd1](https://github.com/seuros/strongroom/commit/be52bd1654b9f104aa8e82eb2129d0751a62f3d1))
|
|
9
|
+
|
|
3
10
|
## [0.3.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.2.0...strongroom-rails-v0.3.0) (2026-10-25)
|
|
4
11
|
|
|
5
12
|
|
data/README.md
CHANGED
|
@@ -107,8 +107,10 @@ At most 16 are supported; names, tables and same-issuer audiences must be distin
|
|
|
107
107
|
Pass actual model classes, never class names from parameters/configuration.
|
|
108
108
|
`register` defaults false; `register: true` needs a client configured with
|
|
109
109
|
`service_endpoint:` and `service_key:` and enables `register_account` and
|
|
110
|
-
`issue_verification` on the account; `issue_recovery
|
|
111
|
-
|
|
110
|
+
`issue_verification` on the account; `issue_recovery`, `events`, `suspend`,
|
|
111
|
+
`reinstate` and `revoke_sessions` need only the service key, and
|
|
112
|
+
`import_account` and `replace_imported_password` a namespace with
|
|
113
|
+
`import = true`. The gem still installs no route.
|
|
112
114
|
|
|
113
115
|
Controllers opt in explicitly and keep their own frontend response contract:
|
|
114
116
|
|
|
@@ -369,7 +371,8 @@ verifies the address.
|
|
|
369
371
|
`Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
|
|
370
372
|
`VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
|
|
371
373
|
`PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
|
|
372
|
-
`PasswordChange`); keep `page.next`
|
|
374
|
+
`PasswordChange`, `AccountSuspend`, `AccountReinstate`); keep `page.next`
|
|
375
|
+
and pass it as `after:`.
|
|
373
376
|
`next` is `nil` for an empty page: ask again later with the cursor you have.
|
|
374
377
|
`page.oldest_at` is the oldest event the kernel still holds; if your last
|
|
375
378
|
event is older, retention pruned events you never read. `Strongroom::CursorAhead`
|
|
@@ -383,6 +386,39 @@ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
|
|
|
383
386
|
a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
|
|
384
387
|
Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
|
|
385
388
|
|
|
389
|
+
## Account control and imports
|
|
390
|
+
|
|
391
|
+
On the client of any keyed namespace:
|
|
392
|
+
|
|
393
|
+
- `suspend(user_id:)` suspends the user's account in the namespace's org: every
|
|
394
|
+
session it holds there ends, its verification and recovery links stop
|
|
395
|
+
working, and nothing authenticates until `reinstate(user_id:)`, which leaves
|
|
396
|
+
those sessions and links ended. Both return a `Strongroom::AccountChange`
|
|
397
|
+
(`state`, `changed`: false for a repeat, `revoked_sessions`).
|
|
398
|
+
- `revoke_sessions(user_id:)` ends every live session of the user in the org
|
|
399
|
+
("sign out everywhere") and returns how many ended.
|
|
400
|
+
- All three raise `Strongroom::NotFound` for a user outside the namespace,
|
|
401
|
+
`Strongroom::AccountDisabled` (suspension and reinstatement) for a disabled
|
|
402
|
+
account, and `Strongroom::Busy` while one of the user's sessions is being
|
|
403
|
+
rotated or logged out: retry, nothing changed.
|
|
404
|
+
|
|
405
|
+
With `import = true` on the namespace:
|
|
406
|
+
|
|
407
|
+
- `import_account(source:, external_id:, identity:, password_hash:,
|
|
408
|
+
account_state:, grant_state:, identity_state:, credential_state:,
|
|
409
|
+
mfa_required:)` imports an account with a pre-hashed password, as
|
|
410
|
+
`strongroom-admin provision` does, and returns a `Strongroom::Import`
|
|
411
|
+
(`user_id`, `identity_id`, `credential_id`, `created`: false for a replay).
|
|
412
|
+
Every state is explicit. It raises `Strongroom::BindingConflict`,
|
|
413
|
+
`Strongroom::IdentityTaken` or `Strongroom::InvalidRequest`.
|
|
414
|
+
- `replace_imported_password(source:, external_id:, expected_password_hash:,
|
|
415
|
+
password_hash:)` replaces that account's password only while it still holds
|
|
416
|
+
`expected_password_hash`, and returns a `Strongroom::PasswordReplacement`
|
|
417
|
+
(`status` `"replaced"` or `"unchanged"`). Once the kernel owns the password
|
|
418
|
+
(a login upgraded the hash, a recovery or password change replaced it), it
|
|
419
|
+
raises `Strongroom::CredentialChanged` and changes nothing: stop sending
|
|
420
|
+
hashes for that account.
|
|
421
|
+
|
|
386
422
|
## Password change and recovery
|
|
387
423
|
|
|
388
424
|
### Changing a password
|
|
@@ -445,6 +481,8 @@ class RecoveryMailer < ApplicationMailer
|
|
|
445
481
|
mail(to: params.fetch(:email), subject: "Choose a new password")
|
|
446
482
|
rescue Strongroom::NotFound
|
|
447
483
|
# No usable account holds the address: without `mail`, nothing is sent.
|
|
484
|
+
rescue Strongroom::IdentityUnverified
|
|
485
|
+
# Registered, never verified: cancel that registration instead (see below).
|
|
448
486
|
end
|
|
449
487
|
end
|
|
450
488
|
```
|
|
@@ -453,8 +491,9 @@ end
|
|
|
453
491
|
`Strongroom::RecoveryToken` redacted everywhere and never a session credential;
|
|
454
492
|
`expires_at`, by default 30 minutes away; `user_id`). Earlier links of that
|
|
455
493
|
address stop working. It raises `Strongroom::NotFound` when no usable account
|
|
456
|
-
holds the address in the namespace
|
|
457
|
-
|
|
494
|
+
holds the address in the namespace, and `Strongroom::IdentityUnverified` when
|
|
495
|
+
the address was registered but never verified: answer your users the same way
|
|
496
|
+
in every case, or the form tells anyone which addresses have accounts. If the token must
|
|
458
497
|
travel through a job after all, only in an argument your own
|
|
459
498
|
`ActiveJob::Serializers::ObjectSerializer` encrypts.
|
|
460
499
|
|
|
@@ -471,16 +510,16 @@ const reply = await fetch(`${STRONGROOM_ORIGIN}/accounts/users/recovery`, {
|
|
|
471
510
|
// usable; 400 {"error": "invalid_token", "reason": "invalid" | "expired"}; 503: retry
|
|
472
511
|
```
|
|
473
512
|
|
|
474
|
-
A completed recovery sets the new password
|
|
475
|
-
the org (in every app) and their other recovery links
|
|
476
|
-
|
|
477
|
-
person to your sign-in page afterwards. A second use of the link answers
|
|
513
|
+
A completed recovery sets the new password and ends every session of the user
|
|
514
|
+
in the org (in every app) and their other recovery links. Send the person to
|
|
515
|
+
your sign-in page afterwards. A second use of the link answers
|
|
478
516
|
`invalid_token`. Links belong to the address across every app of the org.
|
|
479
517
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
518
|
+
Only verified addresses recover: anybody can register an address, and its
|
|
519
|
+
owner would inherit the account whoever registered it prepared (your records
|
|
520
|
+
for that `external_id`). On `Strongroom::IdentityUnverified`, cancel that
|
|
521
|
+
registration and let the owner register afresh; do not send a verification
|
|
522
|
+
link instead, which would let the owner verify that account and recover it.
|
|
484
523
|
|
|
485
524
|
## Running the daemon
|
|
486
525
|
|
data/lib/strongroom/client.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Strongroom
|
|
|
11
11
|
CURSOR = /\A[A-Za-z0-9_-]{34}\z/
|
|
12
12
|
IDENTITY_STATES = %w[Unverified Verified Disabled].freeze
|
|
13
13
|
EVENT_TYPES = %w[Provision Register RegistrationCancel VerificationIssue IdentityVerify PasswordHashUpgrade
|
|
14
|
-
PasswordReplace Revoke RecoveryIssue RecoveryComplete PasswordChange].freeze
|
|
14
|
+
PasswordReplace Revoke RecoveryIssue RecoveryComplete PasswordChange AccountSuspend AccountReinstate].freeze
|
|
15
15
|
LOGIN = {"session_id" => :uuid, "user_id" => :uuid, "expires_at" => :timestamp,
|
|
16
16
|
"identity_verified" => :boolean}.freeze
|
|
17
17
|
METADATA = LOGIN.merge("org_id" => :uuid, "app_id" => :uuid).freeze
|
|
@@ -181,8 +181,11 @@ module Strongroom
|
|
|
181
181
|
# this namespace; earlier recovery links of that address stop working. The
|
|
182
182
|
# browser completes it: POST {"token", "password"} to
|
|
183
183
|
# `/accounts/<namespace>/recovery` on the kernel's public listener. Raises
|
|
184
|
-
# `NotFound` when no usable account holds the address
|
|
185
|
-
# the
|
|
184
|
+
# `NotFound` when no usable account holds the address, and
|
|
185
|
+
# `IdentityUnverified` when the address was registered but never verified:
|
|
186
|
+
# whoever registered it may not own it, so cancel that registration and let
|
|
187
|
+
# the owner register afresh rather than recover it. Answer your users the
|
|
188
|
+
# same way in every case.
|
|
186
189
|
def issue_recovery(identity:)
|
|
187
190
|
raise InvalidRequest, "identity must be a string" unless identity.is_a?(String)
|
|
188
191
|
body = JSON.generate(identity_kind: "email", identity: identity)
|
|
@@ -195,6 +198,76 @@ module Strongroom
|
|
|
195
198
|
raise InvalidRequest, "invalid recovery payload", cause: nil
|
|
196
199
|
end
|
|
197
200
|
|
|
201
|
+
# Suspends the account of `user_id` in this namespace's org: every session
|
|
202
|
+
# it holds there ends, its verification and recovery links stop working,
|
|
203
|
+
# and it authenticates nowhere in the org until `reinstate`. Raises
|
|
204
|
+
# `NotFound`, `AccountDisabled`, or `Busy` while one of its sessions is
|
|
205
|
+
# being rotated or logged out (retry).
|
|
206
|
+
def suspend(user_id:)
|
|
207
|
+
account_change("suspensions", user_id, "Suspended")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Lifts a suspension. The sessions and links it ended stay ended.
|
|
211
|
+
def reinstate(user_id:)
|
|
212
|
+
account_change("reinstatements", user_id, "Active")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Ends every live session of `user_id` in this namespace's org, whatever
|
|
216
|
+
# its account's state, and returns how many ended. `Busy` as for `suspend`.
|
|
217
|
+
def revoke_sessions(user_id:)
|
|
218
|
+
raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
|
|
219
|
+
response = service_request(:post, "revocations", body: JSON.generate(user_id: user_id), statuses: [200])
|
|
220
|
+
object(response, {"revoked_sessions" => :count})["revoked_sessions"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Imports one account with a pre-hashed password (bcrypt or Argon2id), keyed
|
|
224
|
+
# by your `source` and `external_id`, as `strongroom-admin provision` does;
|
|
225
|
+
# the namespace needs `import = true`. Every state is explicit: the kernel
|
|
226
|
+
# refuses MFA and unverified identities. Replaying the same import returns
|
|
227
|
+
# its receipt (`created: false`); a changed one raises `BindingConflict`,
|
|
228
|
+
# an address another account holds `IdentityTaken`.
|
|
229
|
+
def import_account(source:, external_id:, identity:, password_hash:, account_state:, grant_state:,
|
|
230
|
+
identity_state:, credential_state:, mfa_required:, identity_kind: "email")
|
|
231
|
+
fields = {source: source, external_id: external_id, identity_kind: identity_kind, identity: identity,
|
|
232
|
+
account_state: account_state, grant_state: grant_state, identity_state: identity_state,
|
|
233
|
+
credential_state: credential_state, password_hash: password_hash}
|
|
234
|
+
unless fields.values.all?(String) && [true, false].include?(mfa_required)
|
|
235
|
+
raise InvalidRequest, "import fields must be strings and mfa_required a boolean"
|
|
236
|
+
end
|
|
237
|
+
body = JSON.generate(fields.merge(mfa_required: mfa_required))
|
|
238
|
+
raise InvalidRequest, "import request too large" if body.bytesize > 16_384
|
|
239
|
+
response = service_request(:post, "imports", body: body, statuses: [200, 201])
|
|
240
|
+
values = object(response, {"user_id" => :uuid, "identity_id" => :uuid, "credential_id" => :uuid})
|
|
241
|
+
Import.new(user_id: values["user_id"], identity_id: values["identity_id"],
|
|
242
|
+
credential_id: values["credential_id"], created: response.status == 201)
|
|
243
|
+
rescue JSON::GeneratorError
|
|
244
|
+
raise InvalidRequest, "invalid import payload", cause: nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Replaces the password of the account an import bound to `source` and
|
|
248
|
+
# `external_id` with `password_hash`, but only while its active credential
|
|
249
|
+
# still holds `expected_password_hash`, the hash you imported or last
|
|
250
|
+
# replaced. Once the kernel owns the password (a login upgraded its hash, a
|
|
251
|
+
# recovery or password change replaced it) this raises `CredentialChanged`
|
|
252
|
+
# and changes nothing. Its sessions end with the old credential.
|
|
253
|
+
def replace_imported_password(source:, external_id:, expected_password_hash:, password_hash:)
|
|
254
|
+
fields = {source: source, external_id: external_id, expected_password_hash: expected_password_hash,
|
|
255
|
+
password_hash: password_hash}
|
|
256
|
+
raise InvalidRequest, "replacement fields must be strings" unless fields.values.all?(String)
|
|
257
|
+
body = JSON.generate(fields)
|
|
258
|
+
raise InvalidRequest, "replacement request too large" if body.bytesize > 16_384
|
|
259
|
+
response = service_request(:post, "imports/password", body: body, statuses: [200])
|
|
260
|
+
values = object(response, {"status" => :string, "user_id" => :uuid, "identity_id" => :uuid,
|
|
261
|
+
"credential_id" => :uuid, "revoked_credential_id" => :uuid_or_nil})
|
|
262
|
+
unless (values["status"] == "replaced") == !values["revoked_credential_id"].nil? &&
|
|
263
|
+
%w[replaced unchanged].include?(values["status"])
|
|
264
|
+
raise ProtocolError, "invalid service response"
|
|
265
|
+
end
|
|
266
|
+
PasswordReplacement.new(**values.transform_keys(&:to_sym))
|
|
267
|
+
rescue JSON::GeneratorError
|
|
268
|
+
raise InvalidRequest, "invalid replacement payload", cause: nil
|
|
269
|
+
end
|
|
270
|
+
|
|
198
271
|
# One page of the namespace's event feed, in commit-safe order.
|
|
199
272
|
def events(after: nil, limit: 100)
|
|
200
273
|
unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
|
|
@@ -214,6 +287,16 @@ module Strongroom
|
|
|
214
287
|
|
|
215
288
|
private
|
|
216
289
|
|
|
290
|
+
def account_change(suffix, user_id, state)
|
|
291
|
+
raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
|
|
292
|
+
response = service_request(:post, suffix, body: JSON.generate(user_id: user_id), statuses: [200])
|
|
293
|
+
schema = {"state" => :string, "changed" => :boolean}
|
|
294
|
+
schema["revoked_sessions"] = :count if state == "Suspended"
|
|
295
|
+
values = object(response, schema)
|
|
296
|
+
raise ProtocolError, "invalid service response" unless values["state"] == state
|
|
297
|
+
AccountChange.new(state: state, changed: values["changed"], revoked_sessions: values.fetch("revoked_sessions", 0))
|
|
298
|
+
end
|
|
299
|
+
|
|
217
300
|
def configure_service(endpoint, key, transport)
|
|
218
301
|
if endpoint.nil? && key.nil? && transport.nil?
|
|
219
302
|
@service_endpoint = @service_key = @service_transport = nil
|
|
@@ -340,7 +423,9 @@ module Strongroom
|
|
|
340
423
|
when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
|
|
341
424
|
when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
|
|
342
425
|
when "identity_unverified" then raise IdentityUnverified, "identity is not verified"
|
|
343
|
-
when "credential_changed" then raise CredentialChanged, "
|
|
426
|
+
when "credential_changed" then raise CredentialChanged, "the identity's password credential changed"
|
|
427
|
+
when "account_disabled" then raise AccountDisabled, "account is disabled"
|
|
428
|
+
when "no_active_credential" then raise NoActiveCredential, "account has no active password"
|
|
344
429
|
else raise ProtocolError, "invalid service response"
|
|
345
430
|
end
|
|
346
431
|
end
|
|
@@ -398,6 +483,7 @@ module Strongroom
|
|
|
398
483
|
when :uuid then canonical_uuid?(value)
|
|
399
484
|
when :boolean then value == true || value == false
|
|
400
485
|
when :uuid_or_nil then value.nil? || canonical_uuid?(value)
|
|
486
|
+
when :count then value.is_a?(Integer) && value >= 0
|
|
401
487
|
else value.is_a?(String)
|
|
402
488
|
end
|
|
403
489
|
end
|
data/lib/strongroom/errors.rb
CHANGED
|
@@ -21,9 +21,17 @@ module Strongroom
|
|
|
21
21
|
class IdentityUnverified < Conflict; end
|
|
22
22
|
|
|
23
23
|
# A password change from a session issued with a password the identity no
|
|
24
|
-
# longer logs in with: sign in again, then change it.
|
|
24
|
+
# longer logs in with: sign in again, then change it. For an imported
|
|
25
|
+
# password replacement: the kernel's credential no longer holds the expected
|
|
26
|
+
# hash, so the kernel owns that password now. Nothing changed.
|
|
25
27
|
class CredentialChanged < Conflict; end
|
|
26
28
|
|
|
29
|
+
# A suspension or reinstatement of an account that is disabled, which is final.
|
|
30
|
+
class AccountDisabled < Conflict; end
|
|
31
|
+
|
|
32
|
+
# An imported password replacement for an account without an active password.
|
|
33
|
+
class NoActiveCredential < Conflict; end
|
|
34
|
+
|
|
27
35
|
# The feed cursor names a point the kernel's database has not reached (it was
|
|
28
36
|
# restored, failed over or copied): drop the cursor and read from the start.
|
|
29
37
|
class CursorAhead < Conflict; end
|
|
@@ -65,6 +65,21 @@ module Strongroom
|
|
|
65
65
|
client.events(after: after, limit: limit)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# Suspends the user's account in the namespace's org, ending its sessions
|
|
69
|
+
# and links; needs only the service key.
|
|
70
|
+
def suspend(user_id:)
|
|
71
|
+
client.suspend(user_id: user_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reinstate(user_id:)
|
|
75
|
+
client.reinstate(user_id: user_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Ends every live session of the user in the namespace's org.
|
|
79
|
+
def revoke_sessions(user_id:)
|
|
80
|
+
client.revoke_sessions(user_id: user_id)
|
|
81
|
+
end
|
|
82
|
+
|
|
68
83
|
def resolve(session)
|
|
69
84
|
subject = client.subject(session)
|
|
70
85
|
record = @resolver.call(subject)
|
data/lib/strongroom/session.rb
CHANGED
|
@@ -10,6 +10,20 @@ module Strongroom
|
|
|
10
10
|
# changed nothing; `identity_state` is the identity's current state.
|
|
11
11
|
Registration = Data.define(:user_id, :identity_id, :identity_state, :created)
|
|
12
12
|
|
|
13
|
+
# What a suspension or reinstatement did: the account's `state` now
|
|
14
|
+
# ("Suspended" or "Active"), whether this call `changed` it, and how many
|
|
15
|
+
# sessions a suspension ended (0 for a reinstatement or a repeat).
|
|
16
|
+
AccountChange = Data.define(:state, :changed, :revoked_sessions)
|
|
17
|
+
|
|
18
|
+
# An import's receipt. `created` is false for an exact replay, which changed
|
|
19
|
+
# nothing.
|
|
20
|
+
Import = Data.define(:user_id, :identity_id, :credential_id, :created)
|
|
21
|
+
|
|
22
|
+
# A replaced imported password. `status` is "replaced" (the credential
|
|
23
|
+
# `revoked_credential_id` was revoked for `credential_id`) or "unchanged"
|
|
24
|
+
# (the active credential held that hash already; `revoked_credential_id` nil).
|
|
25
|
+
PasswordReplacement = Data.define(:status, :user_id, :identity_id, :credential_id, :revoked_credential_id)
|
|
26
|
+
|
|
13
27
|
# One account event from the namespace's feed. `meta` is a frozen Hash of
|
|
14
28
|
# non-secret IDs (session, credential, token).
|
|
15
29
|
Event = Data.define(:id, :type, :user_id, :identity_id, :at, :meta)
|